
1. 项目概述为什么我们需要亲手写一个 MCP Server最近在跟几个做 AI 应用的朋友聊天发现大家讨论的热点已经从“怎么调 API”变成了“怎么让 AI 用好我的工具”。无论是想给 Claude 加个读取公司内部文档的能力还是让 ChatGPT 能实时查询服务器状态核心问题都指向一点如何安全、高效地将自定义工具和能力“喂”给大模型。这时MCPModel Context Protocol就进入了我们的视野。简单来说MCP 是一个开放协议它定义了大模型客户端与外部工具、数据源服务器之间如何通信。你可以把它想象成 AI 世界的“USB 标准”——只要设备工具符合这个接口标准就能即插即用地被电脑大模型识别和使用。那么自己动手写一个 MCP Server 听起来是不是很“硬核”需要研究复杂的协议规范、处理底层网络通信、设计安全机制如果你被这些预想吓到了那我可以告诉你事实恰恰相反。得益于 Anthropic 官方和社区提供的成熟SDK构建一个功能完整的 MCP Server 的门槛已经变得极低。使用Node.js和TypeScript配合官方 SDK你完全可以在半小时内从一个空文件夹走到一个可部署的、功能可用的 Server。这不仅仅是“Hello World”式的演示而是能处理真实逻辑、可以被 Claude Desktop 或其它兼容 MCP 的客户端直接调用的生产级组件。这篇文章我就将以一个从业者的视角带你走一遍这个“从零到部署”的完整流程。我们会基于Node.js环境使用TypeScript来获得更好的开发体验和类型安全最终实现一个简单的“待办事项Todo管理” MCP Server。这个 Server 将暴露两个核心工具list_todos列出所有待办和add_todo添加新待办。通过这个具体案例你将彻底明白 MCP Server 的骨架是什么、血肉如何填充以及最终如何让它跑起来。无论你是前端开发者想为 AI 赋能还是后端工程师希望暴露服务能力甚至是产品经理想快速验证一个 AI 工具想法这套流程都值得你亲手试一遍。2. 核心概念与工具选型理解 MCP 与 SDK在动手写代码之前我们有必要花几分钟厘清几个核心概念这能让你后续的每一步都走得明明白白而不是机械地复制粘贴。2.1 MCP 协议AI 的“即插即用”总线MCP 的核心思想是解耦与标准化。在没有 MCP 之前如果你想为某个 AI 应用比如 Claude Desktop添加一个自定义功能可能需要修改客户端代码、处理特定的集成逻辑过程繁琐且不可复用。MCP 通过定义一套基于JSON-RPC的通信协议解决了这个问题。你可以这样理解它的工作模式Server服务器也就是我们要写的东西。它封装了具体的功能逻辑比如读写数据库、调用第三方 API、执行系统命令等。它像一个“能力提供者”。Client客户端比如 Claude Desktop、Cursor IDE 或其它兼容 MCP 的应用。它作为“能力消费者”负责发起请求。协议通道两者之间通过stdio标准输入输出或SSEServer-Sent Events等方式建立连接并以JSON-RPC格式传递消息。JSON-RPC 是一种轻量级的远程过程调用协议它规定了如何封装请求method,params,id和响应result,error,id。MCP 协议在此基础上进一步定义了几种关键的资源Resources和工具Tools资源代表可供读取的静态或动态内容比如一个文件、一个网页的当前内容、一个数据库的查询结果视图。客户端可以“读取”资源。工具代表可供调用的操作通常会有副作用比如创建一个文件、发送一封邮件、插入一条数据库记录。客户端可以“调用”工具。我们的 Todo Server 主要聚焦于“工具”的暴露。当我们在 Claude 中输入“帮我看看今天的待办事项”时Claude客户端就会通过 MCP 协议调用我们 Server 上的list_todos工具。2.2 为什么选择 Node.js TypeScript 官方 SDK面对一个协议最原始的做法是自己从头解析 JSON-RPC 消息、管理连接生命周期、处理错误。但这无疑是重复造轮子且容易出错。因此选择一个成熟的SDK软件开发工具包是快速成功的关键。官方 modelcontextprotocol/sdk 的优势Anthropic 官方提供的这个 Node.js SDK 封装了所有 MCP 协议的底层细节。它帮你处理了连接建立、消息序列化/反序列化、请求路由、生命周期管理等一系列繁琐工作。你只需要关注核心业务逻辑定义资源和工具。这极大地降低了开发难度和出错概率。Node.js 的生态与轻量性Node.js 非常适合构建这种 I/O 密集型的网络服务。它的事件驱动、非阻塞模型与 MCP 的通信模式天然契合。同时NPM 上庞大的生态系统意味着你在实现业务逻辑时比如需要连接数据库、发送 HTTP 请求可以轻松找到成熟的库。TypeScript 的类型安全与开发体验这是强烈推荐的一环。MCP SDK 本身就用 TypeScript 编写提供了完整的类型定义。使用 TypeScript 开发你可以在编码阶段就获得智能提示和类型检查避免许多低级错误。例如当你定义工具的参数时IDE 会直接提示你需要的字段和类型这比查阅文档再手动编写 JSON 要高效、准确得多。工具链准备清单Node.js 环境建议安装最新的 LTS 版本如 v20.x。你可以从官网下载安装包或使用nvmNode Version Manager进行管理方便切换版本。包管理器npm随 Node.js 安装或yarn或pnpm皆可。本文使用npm。代码编辑器VS Code 是首选其对 TypeScript 的支持最为完善。注意在安装 Node.js 时如果遇到网络问题导致安装包下载缓慢或失败可以尝试配置国内镜像源如淘宝 NPM 镜像。对于nvm安装 Node.js 时出现的版本错误提示如 “error installing 24.19.0: node.js v24.19.0 is not yet released”通常是因为镜像源的版本列表未及时同步可以尝试nvm ls-remote查看所有远程版本选择一个明确存在的版本号进行安装。3. 30分钟极速实战构建 Todo MCP Server理论铺垫完毕我们现在开始动手。请确保你的终端命令行已就绪。3.1 第一步项目初始化与依赖安装5分钟首先创建一个全新的项目目录并初始化。# 1. 创建项目文件夹并进入 mkdir mcp-todo-server cd mcp-todo-server # 2. 初始化 package.json一路回车或按需填写 npm init -y # 3. 安装 TypeScript 和 Node.js 类型定义开发依赖 npm install -D typescript types/node # 4. 安装核心依赖MCP SDK npm install modelcontextprotocol/sdk # 5. 初始化 TypeScript 配置文件 npx tsc --init安装完成后我们需要调整一下tsconfig.json文件以适配现代 Node.js 开发。用编辑器打开它确保或修改以下关键配置{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true }, include: [src/**/*], exclude: [node_modules] }关键配置解读“target”: “ES2022”编译目标设为较新的 ES 版本以支持现代语法。“module”: “NodeNext”和“moduleResolution”: “NodeNext”这是为了正确解析 Node.js 的 ES 模块。这是一个容易踩坑的点。早期教程或模板可能使用“commonjs”但随着 Node.js 和 SDK 对 ES 模块的推进使用NodeNext能更好地避免后续的模块导入导出问题。“outDir”和“rootDir”指定源代码src和编译输出dist目录保持项目结构清晰。“strict”: true开启严格类型检查这是 TypeScript 的核心价值所在能帮你捕获许多潜在错误。接着创建项目基础结构# 创建源代码目录和入口文件 mkdir src touch src/index.ts # 创建简单的数据存储文件模拟数据库 touch src/todos.json在src/todos.json中我们先初始化一个空数组用于存储待办事项[]3.2 第二步编写 Server 核心逻辑15分钟现在打开src/index.ts开始编写我们 MCP Server 的“心脏”。首先导入必要的模块并初始化一个“内存数据库”这里我们用读写 JSON 文件来模拟简单直观。// src/index.ts import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { CallToolRequestSchema, ListToolsRequestSchema, } from modelcontextprotocol/sdk/types.js; import * as fs from fs/promises; import * as path from path; // 定义 Todo 项的类型 interface TodoItem { id: number; task: string; completed: boolean; createdAt: string; } // 获取 todos.json 文件的路径 const TODO_FILE_PATH path.join(__dirname, todos.json); // 辅助函数读取 Todo 列表 async function readTodos(): PromiseTodoItem[] { try { const data await fs.readFile(TODO_FILE_PATH, utf-8); return JSON.parse(data); } catch (error: any) { // 如果文件不存在或为空返回空数组 if (error.code ENOENT) { return []; } throw error; } } // 辅助函数写入 Todo 列表 async function writeTodos(todos: TodoItem[]): Promisevoid { await fs.writeFile(TODO_FILE_PATH, JSON.stringify(todos, null, 2), utf-8); }接下来创建 MCP Server 实例并定义工具。这是最核心的部分。// 创建 Server 实例 const server new Server( { name: mcp-todo-server, version: 0.1.0, }, { capabilities: { tools: {}, // 声明本 Server 提供工具 }, } ); // 定义工具列出所有待办事项 server.setRequestHandler(ListToolsRequestSchema, async () { return { tools: [ { name: list_todos, description: 获取所有的待办事项列表, inputSchema: { type: object, properties: {}, // 此工具不需要输入参数 }, }, { name: add_todo, description: 添加一个新的待办事项, inputSchema: { type: object, properties: { task: { type: string, description: 待办事项的具体内容, }, }, required: [task], }, }, ], }; });代码解读我们创建了一个Server对象传入了服务器元信息名称、版本和声明的能力这里我们只声明了tools。通过server.setRequestHandler方法我们处理了ListToolsRequestSchema类型的请求。当客户端查询本服务器有哪些工具时我们就返回一个包含list_todos和add_todo两个工具定义的数组。每个工具定义都包含name唯一标识、description给 AI 看的描述和inputSchema输入参数的 JSON Schema。list_todos不需要参数所以properties为空。add_todo需要一个task字符串参数且是必需的required: [‘task’]。然后我们需要处理客户端调用这些工具时的具体逻辑。// 处理工具调用请求 server.setRequestHandler(CallToolRequestSchema, async (request) { const { name, arguments: args } request.params; if (name list_todos) { const todos await readTodos(); return { content: [ { type: text, text: 当前共有 ${todos.length} 条待办事项\n todos.map(t - [${t.completed ? x : }] ID${t.id}: ${t.task} (创建于: ${t.createdAt})).join(\n), }, ], }; } if (name add_todo) { const task args?.task; if (!task || typeof task ! string) { throw new Error(参数错误必须提供字符串类型的 task 参数); } const todos await readTodos(); const newId todos.length 0 ? Math.max(...todos.map(t t.id)) 1 : 1; const newTodo: TodoItem { id: newId, task: task, completed: false, createdAt: new Date().toISOString(), }; todos.push(newTodo); await writeTodos(todos); return { content: [ { type: text, text: ✅ 已成功添加待办事项${task} (ID: ${newId}), }, ], }; } throw new Error(未知的工具名${name}); });代码解读我们处理CallToolRequestSchema请求。request.params中包含了客户端想调用的工具名 (name) 和参数 (arguments)。对于list_todos读取文件将数据格式化为易读的文本返回。返回的content是一个数组其中包含类型为text的对象这是 MCP 协议规定的返回格式之一。对于add_todo首先校验参数然后读取现有列表生成一个新 ID创建新的 Todo 对象写入文件最后返回成功信息。如果收到不认识的工具名则抛出一个错误。最后启动服务器并建立传输层。MCP Server 通常通过stdio标准输入输出与客户端通信这是最简单也是最常见的方式。// 启动 Server async function runServer() { const transport new StdioServerTransport(); await server.connect(transport); console.error(MCP Todo Server 已启动正在通过 stdio 等待连接...); } runServer().catch((error) { console.error(服务器启动失败:, error); process.exit(1); });实操心得在开发过程中务必使用console.error来输出日志因为console.log的输出会污染 stdio 通道干扰与客户端的正常 JSON-RPC 通信导致连接失败。这是一个非常关键的细节。3.3 第三步本地测试与调试5分钟代码写完了怎么验证它是否能工作呢我们需要一个 MCP 客户端来测试。最方便的方式是使用MCP 客户端调试工具比如modelcontextprotocol/sdk包中提供的简易测试工具或者使用mcp-cli。这里我们介绍一种更直观的方法编写一个简单的测试脚本。首先安装一个用于测试的轻量级客户端工具可选你也可以手动模拟消息。为了快速验证我们可以直接使用node命令运行编译后的代码并手动模拟输入但更高效的方法是使用mcp-cli。不过为了流程的完整性我们先完成构建。修改package.json添加构建和启动脚本{ name: mcp-todo-server, version: 0.1.0, description: A simple Todo MCP Server, main: dist/index.js, scripts: { build: tsc, start: node dist/index.js, dev: tsc --watch }, devDependencies: { types/node: ^20.0.0, typescript: ^5.0.0 }, dependencies: { modelcontextprotocol/sdk: ^1.0.0 } }现在编译并运行我们的服务器# 编译 TypeScript 代码 npm run build # 在终端中启动服务器它会等待 stdio 输入 npm start此时终端会打印“MCP Todo Server 已启动正在通过 stdio 等待连接...”并挂起。这说明服务器已经在运行并准备通过标准输入stdin接收 JSON-RPC 请求。为了测试我们需要在另一个终端窗口使用node交互模式或者编写一个简单的测试脚本来模拟客户端发送请求。这里提供一个极简的测试脚本test_client.js// test_client.js - 这是一个非常原始的模拟仅用于验证 Server 基本响应 const { spawn } require(child_process); const serverProcess spawn(node, [dist/index.js]); let outputBuffer ; serverProcess.stdout.on(data, (data) { // MCP Server 的输出响应会在这里 outputBuffer data.toString(); try { // 尝试解析可能完整的 JSON 响应 const lines outputBuffer.split(\n); for (const line of lines) { if (line.trim()) { console.log(Server Response:, JSON.parse(line)); } } outputBuffer ; } catch (e) { // JSON 不完整继续等待数据 } }); serverProcess.stderr.on(data, (data) { console.error(Server Stderr:, data.toString()); }); // 发送一个模拟的 ListTools 请求 setTimeout(() { const request { jsonrpc: 2.0, id: 1, method: tools/list, }; serverProcess.stdin.write(JSON.stringify(request) \n); console.log(Sent request:, request); }, 1000); // 稍后发送一个 add_todo 请求 setTimeout(() { const request { jsonrpc: 2.0, id: 2, method: tools/call, params: { name: add_todo, arguments: { task: 学习 MCP 协议, }, }, }; serverProcess.stdin.write(JSON.stringify(request) \n); console.log(Sent request:, request); }, 2000); // 5秒后退出 setTimeout(() { serverProcess.kill(); process.exit(0); }, 5000);运行这个测试脚本node test_client.js你应该能看到服务器返回的工具列表和成功添加待办事项的响应。这证明了你的 MCP Server 逻辑是通的。4. 集成与部署让 Claude Desktop 用上你的工具本地测试通过后我们的最终目标是让真正的 AI 客户端如 Claude Desktop能够使用这个工具。下面以 Claude Desktop 为例。4.1 配置 Claude DesktopClaude Desktop 支持通过配置文件来添加自定义的 MCP Server。配置文件通常位于macOS:~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:%APPDATA%\Claude\claude_desktop_config.jsonLinux:~/.config/Claude/claude_desktop_config.json如果文件不存在就创建它。编辑这个 JSON 文件添加我们的 Todo Server{ mcpServers: { todo-server: { command: node, args: [ /ABSOLUTE/PATH/TO/YOUR/mcp-todo-server/dist/index.js ] } } }关键配置解析“todo-server”这是你给这个 Server 起的名字可以任意。“command”启动 Server 的命令。因为我们的代码是 Node.js 写的所以是“node”。“args”传递给命令的参数。这里必须使用编译后的 JavaScript 文件的绝对路径。你需要将/ABSOLUTE/PATH/TO/YOUR/替换成你项目dist/index.js的实际路径。重要提示路径中的空格和特殊字符可能导致启动失败。如果路径包含空格在 Windows 上可能需要额外的引号处理或者考虑将项目放在一个无空格的目录下。这是部署时的一个常见坑点。保存配置文件后完全重启 Claude Desktop 应用。重启后Claude 会自动读取配置并在后台启动你指定的 MCP Server。4.2 验证与使用重启 Claude Desktop 后你可以通过以下方式验证集成是否成功查看 Claude 的“连接”状态在某些版本的 Claude Desktop 中设置里可能会有 MCP Server 的连接状态指示。直接与 Claude 对话这是最直接的测试方法。你可以尝试输入“帮我列出所有的待办事项。”“添加一个待办事项写一篇关于 MCP 的博客。”如果配置正确Claude 会理解你的意图并在后台调用对应的工具然后将工具返回的结果呈现给你。你会看到 Claude 的回复中包含了从你的 Server 获取的真实数据。4.3 生产环境部署考量目前我们是在本地开发测试。如果想让团队其他成员或者在其他机器上使用就需要部署。方案一本地网络共享如果你的 Server 和 Claude Desktop 在同一局域网可以将配置中的“command”和“args”替换为通过网络调用本地服务的命令但这通常更复杂不推荐。方案二打包分发更实用的方式是将你的 MCP Server 打包成一个可执行文件这样其他人无需安装 Node.js 环境也能运行。使用pkg或nexe这类工具可以将 Node.js 项目打包成针对不同操作系统Windows、macOS、Linux的单个可执行文件。然后在 Claude Desktop 配置中“command”就直接指向这个可执行文件的路径“args”可以为空或传递必要的参数。优势部署简单环境依赖少。注意打包后的文件体积会比较大因为它包含了 Node.js 运行时。一个简单的pkg打包示例安装 pkgnpm install -g pkg在package.json中添加“bin”: “dist/index.js”执行打包pkg . --targets node20-win-x64,node20-macos-x64,node20-linux-x64根据目标平台选择你会得到mcp-todo-server-win.exe,mcp-todo-server-macos,mcp-todo-server-linux等文件。5. 进阶优化与问题排查一个基础可用的 Server 已经完成。但在实际使用中我们还需要考虑更多。5.1 增强 Server 的健壮性我们的初始版本缺乏错误处理和资源管理。更完善的错误处理在readTodos/writeTodos以及工具处理函数中应该用try…catch包裹并返回符合 MCP 错误格式的响应而不是直接抛出异常导致 Server 崩溃。server.setRequestHandler(CallToolRequestSchema, async (request) { try { // ... 原有的工具逻辑 } catch (error: any) { // 返回结构化的错误信息 return { content: [ { type: text, text: 工具执行失败: ${error.message}, }, ], isError: true, // MCP 协议中可能用于标识错误响应 }; // 或者根据 SDK 要求抛出特定的 Error 类型 // throw new Error(JSON.stringify({ error: error.message })); } });数据持久化与状态管理我们用了简单的 JSON 文件。在生产环境中你可能需要连接真正的数据库如 SQLite、PostgreSQL、MongoDB。这时要注意数据库连接的初始化、管理和关闭。可以在 Server 的initialize生命周期钩子中创建连接在shutdown钩子中关闭连接。工具参数的复杂校验对于add_todo我们只检查了task是否存在。现实中你可能需要校验长度、去除首尾空格、检查敏感词等。5.2 扩展更多 MCP 功能目前我们只实现了“工具”。MCP 协议还有“资源”、“提示模板”等概念。添加资源Resources例如你可以暴露一个todo://list的资源URI客户端可以读取它来获取待办列表的“快照”。这需要处理ListResourcesRequestSchema和ReadResourceRequestSchema。server.setRequestHandler(ListResourcesRequestSchema, async () ({ resources: [{ uri: todo://list, name: 待办事项总览, description: 当前所有的待办事项, mimeType: application/json, }] })); server.setRequestHandler(ReadResourceRequestSchema, async (request) { if (request.params.uri todo://list) { const todos await readTodos(); return { contents: [{ uri: request.params.uri, mimeType: application/json, text: JSON.stringify(todos, null, 2) }] }; } throw new Error(Resource not found: ${request.params.uri}); });添加提示模板Prompts你可以预定义一些提示词模板方便客户端快速调用。例如一个“总结待办”的模板。5.3 常见问题排查实录在开发和集成过程中你可能会遇到以下问题Claude Desktop 启动后找不到/不调用工具检查点1配置文件路径和格式。确保 JSON 格式正确路径是绝对路径且无误。可以尝试在终端中直接运行node /ABSOLUTE/PATH/TO/dist/index.js看 Server 能否独立启动。检查点2查看 Claude Desktop 日志。Claude Desktop 通常会在应用数据目录下生成日志文件里面可能有加载 MCP Server 失败的具体原因如命令执行错误、超时等。检查点3Server 的 stdio 输出。确保你的 Server 没有因为未捕获的异常而立即退出。可以在启动命令中增加日志输出或者使用console.error打印更多状态信息。工具调用超时或无响应可能原因工具处理函数执行时间过长或者陷入了死循环。确保你的业务逻辑是异步非阻塞的对于耗时操作如网络请求要做好超时控制。排查方法在工具函数内部添加详细的console.error日志记录开始和结束时间观察执行流程。类型错误或编译错误症状运行npm run build失败。解决仔细阅读 TypeScript 的错误信息。常见问题包括导入路径错误。确保从‘modelcontextprotocol/sdk/server/index.js’导入注意子路径。tsconfig.json配置不当特别是module和moduleResolution。坚持使用“NodeNext”。SDK 版本与 TypeScript 版本不兼容。尝试安装指定版本的 SDK (npm install modelcontextprotocol/sdklatest) 和 TypeScript。“Transport already closed” 或连接错误可能原因Server 代码中存在导致进程退出的未捕获异常或者主动调用了process.exit()。解决用try…catch包裹所有可能出错的代码块并在runServer()函数外使用process.on(‘uncaughtException’, …)和process.on(‘unhandledRejection’, …)来捕获全局异常避免进程退出。一个实用的调试技巧在开发初期可以暂时修改 Claude Desktop 的配置将 Server 的“command”改为一个能输出信息的脚本或者直接指向一个始终打印“hello”的简单程序以确认配置加载机制本身是正常的。排除配置问题后再聚焦于 Server 自身的逻辑。回过头看从创建一个空文件夹到拥有一个能被 Claude 调用的功能 Server核心代码其实不到 100 行。复杂的协议通信、连接管理都被 SDK 消化了。真正的难点和价值在于你如何设计工具的参数、如何实现稳定高效的业务逻辑、如何处理各种边界情况。这 30 分钟你搭建的不只是一个玩具而是一个符合工业标准、具备强大扩展性的 AI 能力插槽。接下来无论是连接数据库、集成内部 API还是封装复杂的业务流程都只是在这个坚实的基础上添砖加瓦而已。