从零构建MCP服务:AI Agent扩展与外部工具集成实战
1. 项目概述从零构建你的第一个MCP服务最近在AI应用开发圈里MCPModel Context Protocol这个词的热度越来越高。简单来说它就像是为AI大模型比如Claude、GPTs连接外部工具和数据源的一套“标准插槽”。你不再需要为每个AI应用单独写一套复杂的集成代码而是可以开发一个通用的MCP服务然后让任何兼容MCP的AI助手去调用它。这极大地提升了AI Agent的扩展性和实用性。“vivy-yi/mcp-tutorial”这个项目就是一个绝佳的入门指南。它手把手教你如何从零开始构建并部署一个属于自己的MCP服务。无论你是想为团队内部打造一个查询数据库的AI助手还是想开发一个能控制智能家居的AI大脑亦或是简单地想学习下一代AI应用的基础架构这个教程都能为你铺平道路。它不只是一个代码仓库更是一张清晰的地图指引你理解MCP的核心概念、工具链和部署流程。接下来我将结合自己搭建和调试MCP服务的经验为你深度拆解这个项目的精髓并补充大量官方文档可能不会提及的实操细节和避坑指南。2. MCP核心概念与项目架构解析在动手写代码之前我们必须先搞清楚MCP到底在解决什么问题以及“vivy-yi/mcp-tutorial”这个项目是如何组织代码来实现这些目标的。理解这些能让你在后续开发中事半功倍。2.1 为什么需要MCP—— 解决AI的“信息孤岛”问题想象一下你有一个非常聪明的AI助手但它就像一个与世隔绝的学者只知道训练数据里的知识。你想让它帮你查一下公司内部数据库的销售数据、发一封邮件或者控制一下房间的灯光它都会告诉你“我做不到”。传统的解决方案是“提示词工程”或“函数调用”但这种方式往往是“一个AI应用一套定制代码”耦合度高难以复用。MCP的出现就是为了标准化AI与外部世界的交互方式。它定义了一套简单的协议服务器Server提供工具Tools和资源Resources客户端Client通常是AI助手来发现和调用这些工具。这样一来你可以开发一个“数据库查询MCP服务”然后同时被Claude Desktop、Cursor AI等多种客户端使用。这种解耦带来了巨大的灵活性。“vivy-yi/mcp-tutorial”项目的目标就是教你构建这样一个“服务器”。它通常会引导你创建一个提供基础工具比如计算器、时间查询、文件读取的服务让你快速体验完整的开发-调试-部署流程。2.2 项目技术栈与目录结构剖析一个典型的MCP教程项目会基于Node.js或Python因为MCP官方对这两种语言的支持最完善。以Node.js为例项目核心会依赖modelcontextprotocol/sdk这个官方SDK。我们来看看一个经过实践检验的、更清晰的项目目录结构应该是什么样的这比基础教程往往更贴近真实项目mcp-my-first-server/ ├── package.json ├── tsconfig.json ├── src/ │ ├── index.ts # 服务器主入口初始化并启动服务 │ ├── tools/ # 工具定义模块 │ │ ├── calculator.ts │ │ ├── time.ts │ │ └── index.ts # 统一导出所有工具 │ ├── resources/ # 资源定义模块可选 │ └── server.ts # 服务器核心逻辑配置工具和资源 ├── scripts/ │ └── build.mjs # 构建脚本 └── dist/ # 编译输出目录为什么这样设计工具模块化将每个工具Tool独立成文件便于维护和扩展。明天你想加一个“天气查询”工具只需要在tools/下新建一个weather.ts然后在index.ts中导出即可。分离入口与核心逻辑index.ts只负责启动server.ts负责组装。这符合单一职责原则测试和调试也更方便。独立的构建脚本教程可能直接用ts-node运行但真实项目需要构建成JS。一个独立的build.mjs脚本可以处理更复杂的构建需求比如清理旧目录、复制静态文件等。实操心得在项目初期就采用这种结构虽然看起来稍显复杂但能为后续扩展省下大量重构时间。很多初学者喜欢把所有代码写在一个文件里当工具数量超过5个时文件就会变得难以阅读和维护。2.3 MCP协议的核心交互模型理解以下三个核心概念是读懂后续代码的关键工具ToolsAI可以调用的函数。每个工具都有name名称、description给AI看的描述和inputSchema输入参数JSON Schema。例如一个“加法计算器”工具输入参数是{a: number, b: number}。资源ResourcesAI可以读取的静态或动态数据。例如一个“服务器日志”资源其URI统一资源标识符可能是file:///var/log/app.logAI可以通过MCP读取其内容。请求/响应Request/Response通信基于JSON-RPC 2.0。服务器启动后客户端会发送initialize请求服务器回复自己支持的能力如有哪些工具。当用户要求AI执行任务时AI会发送tools/call请求来调用特定工具。一个至关重要的补充Stdio标准输入输出传输层教程中可能会简单提到服务器通过stdin/stdout与客户端通信。这背后的深意是MCP服务器是一个独立的进程。AI客户端如Claude Desktop会启动你的服务器进程并通过管道pipe与之交换JSON-RPC消息。这意味着你的服务器不能依赖HTTP服务器所有日志应该输出到stderr以免污染通信信道。踩坑记录早期我曾不小心在工具函数里用了console.log来输出结果这直接导致客户端解析消息失败因为console.log输出到了stdout与协议消息混在了一起。正确的做法是所有业务日志用console.error而工具的执行结果必须通过SDK提供的响应方法如new CallToolResult()返回。3. 从零开始实现一个功能完整的MCP服务现在我们进入实战环节。我将以一个比基础教程更丰富的“增强型计算与信息查询服务器”为例带你一步步实现并解释每一个关键决策背后的原因。3.1 环境准备与项目初始化首先确保你的开发环境就绪node --version # 推荐 18.x npm --version # 或使用 yarn/pnpm然后初始化项目并安装核心依赖mkdir mcp-my-first-server cd mcp-my-first-server npm init -y npm install modelcontextprotocol/sdk npm install -D typescript ts-node types/node为什么选择TypeScript虽然JavaScript也可以但TypeScript的接口Interface和类型提示对于定义复杂的inputSchema和数据结构有巨大帮助能极大减少运行时错误。MCP SDK本身也提供了完整的TypeScript类型定义。初始化tsconfig.json{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true }, include: [src/**/*], exclude: [node_modules] }3.2 定义你的第一个工具智能计算器让我们在src/tools/calculator.ts中创建一个不止能做加减乘除的计算器。import { Tool } from modelcontextprotocol/sdk/server.js; export const calculatorTool: Tool { name: advanced_calculator, description: 执行数学运算。支持加()、减(-)、乘(*)、除(/)、幂(^)和平方根(sqrt)。例如“计算3的平方加4的平方根”。, inputSchema: { type: object, properties: { expression: { type: string, description: 数学表达式如 3 5 * 2 或 sqrt(16) 2^3。支持 , -, *, /, ^, sqrt()。, }, }, required: [expression], }, };注意这里我定义了一个expression参数而不是a和b。这是更符合AI自然语言交互的方式。用户可以说“计算3加5乘以2”AI可以尝试解析并拼接成3 5 * 2的字符串传给工具。工具的实现逻辑在下一步需要包含一个简单的表达式解析器或使用eval在受控环境下。工具处理函数的实现 工具定义和处理函数是分离的。我们在server.ts中实现处理逻辑// 在 server.ts 中 async function handleCalculator(args: { expression: string }): Promisestring { const { expression } args; // 安全警告在生产环境中绝对不要直接使用eval解析用户输入。 // 这里仅为演示实际应使用安全的数学表达式解析库如 math.js 或 expr-eval。 try { // 简单的安全替换和预处理 let safeExpr expression .replace(/sqrt\(/g, Math.sqrt() .replace(/\^/g, **); // 将 ^ 替换为 JS的幂运算符 // 非常基础的验证只允许数字、运算符、括号、Math. 和空格 if (!/^[\d\-*/().\sMath.sqrt]$/.test(safeExpr)) { throw new Error(表达式包含非法字符); } // 使用Function构造器在沙盒环境中计算比eval稍安全但生产环境仍需更严格的库 const result new Function(return safeExpr)(); return 表达式 ${expression} 的计算结果是: ${result}; } catch (error: any) { return 计算失败: ${error.message}。请检查表达式格式是否正确。; } }核心安全提示这是教程中最容易忽略也最危险的一点。永远不要相信来自AI客户端的输入。即使AI本身是善意的用户也可能通过AI传入恶意字符串。上述代码的验证非常初级真实项目必须使用像math.js这样的专业库它内置了安全的表达式解析和计算功能能彻底避免代码注入风险。3.3 实现动态资源一个简单的键值存储除了工具MCP还支持资源Resources。资源更适合表示“状态”或“信息”AI可以读取read甚至订阅其变更subscribe。我们实现一个简单的“笔记”资源AI可以读取或更新它。首先在src/resources/下创建// src/resources/notes.ts export interface Note { id: string; title: string; content: string; updatedAt: string; } // 一个简单的内存存储 export class NoteResourceManager { private notes: Mapstring, Note new Map(); constructor() { // 初始化一条示例笔记 this.notes.set(welcome, { id: welcome, title: 欢迎使用MCP笔记, content: 这是通过MCP服务管理的第一条笔记。AI助手可以读取和修改它。, updatedAt: new Date().toISOString(), }); } getNoteUri(noteId: string): string { return notes://${noteId}; } getNote(noteId: string): Note | undefined { return this.notes.get(noteId); } listNotes(): { uri: string; mimeType: string; name: string }[] { return Array.from(this.notes.keys()).map((id) ({ uri: this.getNoteUri(id), mimeType: text/plain, name: 笔记: ${this.notes.get(id)?.title || id}, })); } updateNote(noteId: string, content: string): boolean { const note this.notes.get(noteId); if (note) { note.content content; note.updatedAt new Date().toISOString(); return true; } return false; } }然后在服务器中注册这个资源并提供一个工具来修改它// 在 server.ts 中 import { NoteResourceManager } from ./resources/notes.js; const noteManager new NoteResourceManager(); // 1. 注册资源列表 server.setRequestHandler(ListResourcesRequestSchema, async () { return { resources: noteManager.listNotes(), }; }); // 2. 处理资源读取请求 server.setRequestHandler(ReadResourceRequestSchema, async (request) { const uri request.params.uri; const noteId uri.replace(notes://, ); const note noteManager.getNote(noteId); if (note) { return { contents: [{ uri, mimeType: text/plain, text: 标题: ${note.title}\n\n${note.content}\n\n最后更新: ${note.updatedAt}, }], }; } throw new Error(未找到笔记: ${noteId}); }); // 3. 提供一个“更新笔记”的工具 const updateNoteTool: Tool { name: update_note, description: 更新指定ID的笔记内容。, inputSchema: { type: object, properties: { noteId: { type: string, description: 笔记的ID例如 welcome。, }, newContent: { type: string, description: 笔记的新内容。, }, }, required: [noteId, newContent], }, }; async function handleUpdateNote(args: { noteId: string; newContent: string }): Promisestring { const success noteManager.updateNote(args.noteId, args.newContent); if (success) { return 笔记 ${args.noteId} 已成功更新。; } else { return 更新失败未找到ID为${args.noteId}的笔记。; } }通过这个例子你可以看到**工具Tools和资源Resources**的典型区别工具是“动作”用于执行操作资源是“物体”用于表示数据。AI可以先通过listResources发现有一个“欢迎笔记”然后read它查看内容再调用update_note工具修改它。3.4 组装服务器并处理连接最后我们在src/server.ts中将所有部分组装起来并启动服务器import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from modelcontextprotocol/sdk/types.js; // 导入工具和处理器 import { calculatorTool, updateNoteTool } from ./tools/index.js; import { handleCalculator, handleUpdateNote } from ./handlers.js; // 假设处理器统一放在handlers.ts const server new Server( { name: my-first-mcp-server, version: 1.0.0, }, { capabilities: { tools: {}, // 告知客户端本服务器支持工具 resources: {}, // 告知客户端本服务器支持资源 }, } ); // 注册工具列表 server.setRequestHandler(ListToolsRequestSchema, async () { return { tools: [calculatorTool, updateNoteTool], }; }); // 注册资源列表略见上一节 // 注册资源读取处理器略见上一节 // 注册工具调用处理器 server.setRequestHandler(CallToolRequestSchema, async (request) { const { name, arguments: args } request.params; console.error([DEBUG] 收到工具调用请求: ${name}, args); try { let result: string; switch (name) { case calculatorTool.name: result await handleCalculator(args as any); break; case updateNoteTool.name: result await handleUpdateNote(args as any); break; default: throw new Error(未知的工具: ${name}); } return { content: [{ type: text, text: result }], }; } catch (error: any) { console.error([ERROR] 工具执行失败: ${error.message}); return { content: [{ type: text, text: 工具执行出错: ${error.message} }], isError: true, }; } }); // 启动服务器使用stdio传输层 async function main() { const transport new StdioServerTransport(); await server.connect(transport); console.error([INFO] MCP服务器已启动等待连接...); } main().catch((error) { console.error([FATAL] 服务器启动失败:, error); process.exit(1); });在src/index.ts中我们只需简单地启动服务器import ./server.js;4. 本地调试、打包与客户端配置代码写完了怎么验证它是否能被AI客户端正确调用呢这是新手最容易卡住的地方。4.1 使用MCP Inspector进行本地调试Anthropic官方提供了一个强大的调试工具MCP Inspector。它是一个图形化界面可以模拟AI客户端让你直接测试工具调用和资源读取。首先全局安装Inspectornpm install -g modelcontextprotocol/inspector然后编写一个简单的启动脚本inspector.json或直接在package.json中配置{ command: node, args: [dist/index.js], env: { NODE_ENV: development } }使用以下命令启动Inspectormcp-inspector --config inspector.jsonInspector会启动一个本地网页左侧是你的服务器日志来自stderr右侧你可以手动触发listTools、callTool等请求。这是开发阶段不可或缺的环节你可以在这里反复测试工具的参数和返回值格式确保一切符合预期。调试技巧在工具处理函数中多使用console.error输出详细的中间状态。这些日志会在Inspector的日志面板中显示帮助你定位问题。例如在handleCalculator中可以打印接收到的原始表达式和安全处理后的表达式。4.2 构建与打包为可执行文件为了让Claude Desktop等客户端能方便地启动你的服务器最好将其打包成一个独立的可执行脚本。编译TypeScript在package.json中添加脚本。scripts: { build: tsc, start: node dist/index.js }运行npm run build后所有代码会被编译到dist/目录。创建入口脚本对于Unix系统macOS, Linux可以在项目根目录创建一个名为mcp-my-server的脚本无后缀#!/usr/bin/env node require(./dist/index.js);然后赋予执行权限chmod x mcp-my-server。在package.json中指定binbin: { mcp-my-server: ./mcp-my-server }这样用户通过npm install -g .全局安装你的包后就可以直接在命令行使用mcp-my-server命令启动服务了。4.3 配置Claude Desktop客户端这是最后一步也是让成果“活”起来的一步。Claude Desktop允许你通过配置文件添加自定义MCP服务器。找到Claude Desktop的配置文件夹macOS:~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:%APPDATA%\Claude\claude_desktop_config.jsonLinux:~/.config/Claude/claude_desktop_config.json编辑该JSON文件在mcpServers对象中添加你的服务器配置{ mcpServers: { my-first-server: { command: node, args: [/ABSOLUTE/PATH/TO/YOUR/PROJECT/dist/index.js] // 或者如果你打包成了全局命令 // command: mcp-my-server } } }关键点args中的路径必须是绝对路径。使用相对路径会导致启动失败。重启Claude Desktop。重启后你可以在与Claude的对话中尝试“你能用什么工具”或者直接说“帮我计算一下(157)*3的平方根”。如果配置成功Claude会识别出你服务器提供的工具并调用它。5. 进阶实践与生产环境考量当你成功运行了第一个MCP服务后可能会考虑更复杂的场景。以下是一些进阶思路和必须注意的生产环境问题。5.1 处理异步操作与长时任务有些工具操作可能是耗时的比如调用一个外部API查询天气。MCP协议支持异步响应但你需要确保不阻塞主线程。async function handleQueryWeather(args: { city: string }): Promisestring { // 模拟一个耗时的网络请求 console.error([INFO] 开始查询 ${args.city} 的天气...); const weatherData await fetchWeatherFromAPI(args.city); // 假设这是一个返回Promise的异步函数 console.error([INFO] 查询完成。); return 城市 ${args.city} 的天气是: ${weatherData}; }关键在于你的处理函数handler必须是async的并且SDK会妥善处理异步响应。千万不要在工具处理器中执行同步的长时间循环或阻塞操作这会导致整个MCP通信卡死。5.2 错误处理与用户友好提示工具调用可能因各种原因失败网络错误、无效输入、权限不足等。向AI返回清晰的错误信息至关重要这样AI才能向用户转达有用的反馈。async function handleFetchData(args: { url: string }): Promisestring { try { const response await fetch(args.url); if (!response.ok) { // 返回结构化的错误信息方便AI理解 throw new Error(HTTP ${response.status}: ${response.statusText}); } const data await response.text(); return 获取成功内容长度: ${data.length} 字符。; } catch (error: any) { // 区分网络错误、解析错误等 const errorType error.code || UNKNOWN; const message 无法从 ${args.url} 获取数据 (错误类型: ${errorType})。请检查网址是否有效且可访问。; console.error([ERROR] handleFetchData失败:, error); // 返回错误信息并标记为错误 // 注意在CallToolResult中我们可以设置 isError: true // 但更常见的做法是在返回的文本中明确说明并由AI判断。 return [操作失败] ${message}; } }5.3 安全性加固输入验证与权限控制这是从“玩具项目”到“可用的服务”最关键的一跃。严格的输入验证如前所述对任何来自客户端的输入都进行白名单验证。对于计算器使用math.js库。对于文件路径解析后检查是否在允许的目录范围内防止路径遍历攻击。环境变量管理敏感信息如果你的工具需要API密钥如查询天气、发送邮件绝对不要硬编码在代码中。使用dotenv等库从环境变量或安全的配置服务中读取。import dotenv/config; const API_KEY process.env.WEATHER_API_KEY; if (!API_KEY) { throw new Error(WEATHER_API_KEY环境变量未设置); }基于上下文的权限控制高级理论上MCP协议可以传递用户会话信息。你可以在服务器中设计简单的权限逻辑例如某些工具只允许在特定的聊天会话中被调用。这通常需要客户端支持并传递额外的上下文信息。5.4 性能优化与状态管理避免全局状态污染我们的NoteResourceManager使用了内存存储服务器重启后数据就丢失了。对于生产环境你需要连接数据库如SQLite、PostgreSQL或使用文件进行持久化。工具懒加载如果初始化工具列表非常耗时可以考虑动态注册。在listTools请求到来时再初始化而不是在服务器启动时全部加载。资源订阅Subscribe对于频繁变化的数据如股票价格、服务器监控指标可以实现资源的subscribe功能。当资源变化时服务器可以主动向客户端推送通知notify。这需要更复杂的双向通信管理但对实时性要求高的场景非常有用。6. 常见问题排查与实战心得结合我自己和社区里常见的问题这里整理了一份速查表。问题现象可能原因排查步骤与解决方案Claude Desktop提示“无法连接到MCP服务器”或毫无反应1. 配置文件路径错误。2. 服务器启动命令或路径错误。3. 服务器进程启动后立即崩溃。1.检查配置文件路径确保claude_desktop_config.json文件在正确位置且JSON格式正确可用JSON验证器。2.检查命令和路径args中的路径必须是绝对路径。在终端中手动运行该命令看是否能启动。3.查看服务器日志在配置中暂时去掉stdio重定向让服务器日志输出到终端查看崩溃原因。通常是未捕获的异常或模块找不到。在Inspector中能listTools但callTool失败1. 工具处理器handler未正确注册或函数名不匹配。2. 工具参数inputSchema与处理器接收的参数结构不匹配。3. 处理器函数内部抛出未捕获的异常。1.核对工具名在CallToolRequestSchema的handler中switch语句的case值必须与工具定义的name字段完全一致。2.检查参数类型使用TypeScript严格模式确保args as any转换前的类型预期正确。在处理器开头用console.error打印收到的args。3.添加Try-Catch确保每个处理器都有最外层的try-catch并返回错误信息。工具调用成功但AI助手“看不到”或“不理解”结果1. 返回的结果格式不符合AI的预期。2. 工具描述description不够清晰导致AI不知道何时使用它。1.优化返回文本结果应该是清晰、完整的自然语言句子而不是纯JSON或代码。例如返回“计算结果是42”而不是{“result”: 42}。2.完善工具描述在description中详细说明工具的用途、适用场景和参数示例。AI特别是Claude非常依赖描述来理解工具功能。服务器运行一段时间后失去响应1. 内存泄漏。2. 未处理的Promise拒绝导致进程不稳定。3. 外部API调用超时未设置超时时间挂起。1.监控内存使用node --inspect调试或添加简单的内存日志。2.全局捕获未处理的Promise在index.ts开头添加process.on(unhandledRejection, (reason, promise) { console.error(未处理的Promise拒绝:, reason); });。3.为所有外部调用设置超时使用Promise.race或AbortController避免无限等待。最后一点个人体会开发MCP服务最有成就感的一刻是看到AI助手自然而然地运用你创造的工具仿佛它真的拥有了这些能力。从简单的计算器开始逐步扩展到查询数据库、管理日程、控制物联网设备这个过程就像在为AI打造一套专属的“瑞士军刀”。最重要的不是工具的复杂度而是可靠性和用户体验。一个描述清晰、响应迅速、错误处理完善的基础工具远比一个功能强大但难以使用的高级工具更有价值。先从解决一个小痛点开始迭代优化你会发现自己正在构建未来人机交互的基石。