基于NestJS构建高效MCP服务:AI应用与工具集成的工程化实践

发布时间:2026/9/12 5:08:52
基于NestJS构建高效MCP服务:AI应用与工具集成的工程化实践 1. MCP是什么为什么用NestJS来写1.1 MCP协议的核心概念MCPModel Context Protocol翻译过来就是模型上下文协议它解决的其实是AI应用和外部数据、工具之间的互联问题。想象一个场景你写的AI应用需要去查数据库、调内部API、读文件如果每次对接一个数据源就要单独写一套适配逻辑那做十个工具就得写十套对接代码维护成本直接失控。MCP的做法是定义一套统一的标准接口把AI模型和能力提供方解耦——能力提供方实现一个MCP ServerAI客户端比如Claude Desktop、各类智能体框架按照协议去调用两边各管各的不需要互相迁就。在MCP协议里有几个核心概念必须先搞明白Tool工具可被AI主动调用的函数比如查用户信息创建订单。这是大部分场景下用得最多的能力单元也是我这篇文章的重点。Resource资源暴露给AI读取的数据或文件比如项目的说明文档、某个数据库的表结构。Prompt提示词模板预定义好的提示词模板方便AI按固定套路执行任务适合处理重复性较高的交互场景。从传输方式来说MCP Server支持stdio和SSE两种主流模式。stdio适合本地进程通信比如你在本地跑一个node进程让Claude去调用简单直接SSE则适合远程部署通过HTTP把服务暴露给局域网甚至公网上的客户端适合团队共享。具体用哪种取决于你的部署场景后面我会分别讲到。1.2 为什么选NestJS而不是直接用Node脚本说句公道话如果只是写一个给AI用的简单查询工具用裸Node加上modelcontextprotocol/sdk就够了二十多行代码就能跑起来一个可用的server。那为什么我还要推荐用NestJS原因其实很现实MCP服务一旦接入真实业务它就不再仅仅是一个工具而是一个需要对接数据库、内部API、权限体系、日志监控、配置中心的后端服务。这时候NestJS的优势就体现出来了模块化架构你可以把MCP工具按业务域拆成一个个Module每个Module只负责自己的依赖和工具注册不会出现一个文件几百行的尴尬场面。依赖注入DI工具里要用的Service、Repository通过构造函数注入就行写测试和替换实现都很方便。生命周期管理NestJS提供的onModuleInit、onModuleDestroy能帮你处理好MCP Server的启动和优雅关闭不用担心进程残留的问题。生态成熟class-validator、TypeORM、Prisma、NestJS官方Logger这些都能无缝整合做起业务来很顺手。说白了MCP Server的协议入口本身很轻量但承载它的业务底座需要工程化能力。NestJS恰好把这个底座给你搭好了你只需要专注于写业务工具本身。1.3 整体技术架构我这边的落地方案是这样的框架NestJS 10 TypeScriptMCP SDKmodelcontextprotocol/sdk1.x版本参数校验zodMCP SDK原生支持用zod描述工具入参的schema数据层先用TypeORM连MySQL实现用户信息、订单数据的查询部署形态本地开发直接用stdio线上用SSE挂在网关后面工具层面我规划了三个查询用户基本信息、查询用户订单列表、统计订单金额。这三个工具有简单查询也有聚合逻辑基本能覆盖MCP服务开发的大部分技术点看完你能照着举一反三。2. 环境准备与项目初始化2.1 基础环境要求开始之前先确认环境这一步我踩过坑所以多说两句。Node.js版本建议至少18.0.0而且一定要装LTS版本。MCP SDK的1.x对Node版本有硬性要求如果你还在用Node 16跑老项目大概率装包就报错或者运行时报模块解析错误别问我怎么知道的。确认环境可以跑一遍node -v npm -v nest --version如果nest命令没装执行npm install -g nestjs/cli包管理器方面npm、pnpm、yarn都行我个人用pnpm装包速度快磁盘占用也小团队协作的话统一一个就行不要混着用。2.2 创建NestJS项目用CLI创建一个全新的NestJS项目nest new nest-mcp-service cd nest-mcp-service创建过程中会让你选择包管理器按你自己习惯选。项目创建好之后先把NestJS默认的AppModule、AppService、AppController这些样例代码清理掉或者暂时留着也不影响后面我们会用独立的MCP模块来承载核心逻辑。如果不想用CLI也可以手动搭一个TypeScript项目但既然NestJS官方脚手架都给你准备好了没必要重复造轮子。新建项目的时间很短反而能帮你把目录结构理清楚。2.3 安装MCP相关依赖这一步是整个项目的基础依赖版本一定要看仔细pnpm add modelcontextprotocol/sdk zod安装完成后在package.json里确认一下版本号。我写这篇文章时用的SDK版本已经到1.x了如果你是从老版本升级过来的注意API变化比较大。1.x版本的API比之前稳定很多核心就是McpServer类和server.tool()方法大部分生态工具都已经按照这套API在适配。这里补充一点SSE远程部署的模式下需要一个HTTP服务器来承载SSE连接NestJS本身就能干这活所以后续我直接在NestJS的Controller里挂SSE路由不需要额外引入其他框架这一点也是NestJS整合MCP的一个天然优势。3. MCP服务核心实现从模块到工具3.1 设计MCP模块结构NestJS的开发习惯是模块化MCP服务也不例外。我建议在src下单独建一个mcp模块整体结构长这样src/ ├── mcp/ │ ├── mcp.module.ts │ ├── mcp.service.ts │ └── tools/ │ ├── user.tools.ts │ └── order.tools.ts ├── user/ │ ├── user.module.ts │ ├── user.service.ts │ └── user.entity.ts ├── order/ │ ├── order.module.ts │ ├── order.service.ts │ └── order.entity.ts └── main.ts这么设计的思路是MCP模块只负责和协议打交道具体的业务逻辑全在user模块和order模块里MCP模块通过NestJS的依赖注入拿到业务Service的实例再包装成工具暴露出去。以后要新增工具只需要在tools目录里加一个文件并在MCP模块里注册完全不用动业务代码。这个结构我用了很久工具多了也能保持清晰。3.2 实现MCP Server服务类核心是mcp.service.ts。我这里直接给一个能跑的最小实现把MCP协议层面的东西都封装在服务类里import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from nestjs/common; import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { z } from zod; import { UserService } from ../user/user.service; import { OrderService } from ../order/order.service; Injectable() export class McpService implements OnModuleInit, OnModuleDestroy { private readonly logger new Logger(McpService.name); private server: McpServer; private transport: StdioServerTransport; constructor( private readonly userService: UserService, private readonly orderService: OrderService, ) {} async onModuleInit() { this.server new McpServer({ name: nest-mcp-service, version: 1.0.0, }); this.registerUserTools(); this.registerOrderTools(); this.registerResources(); await this.connectStdio(); this.logger.log(MCP Server started via stdio); } private async connectStdio() { this.transport new StdioServerTransport(); await this.server.connect(this.transport); } async onModuleDestroy() { if (this.server) { await this.server.close(); this.logger.log(MCP Server closed); } } }这段代码有几个地方值得细说。首先是McpServer的初始化参数name和version会在客户端握手的时候被读取方便客户端识别是哪个服务名字尽量起得有意义一点比如带上业务名称和用途。然后是registerUserTools这些方法就是注册工具的地方稍后展开。connectStdio这一步把server和stdio传输通道连接起来数据就通过标准输入输出来流通。这里有个对NestJS开发者来说非常关键的细节纯stdio模式下MCP服务一般不调用app.listen()启动HTTP端口因为它就是一个通过标准输入输出通信的进程不需要监听端口。但如果你在同一个NestJS应用里既要跑MCP又要对外提供HTTP接口监听端口也没问题两者互不冲突后面我会讲怎么用SSE模式同时对外暴露HTTP路由。3.3 用server.tool()注册业务工具工具注册是MCP服务的重头戏。我们通过zod来描述工具的入参这样AI模型能自动理解每个参数的含义。我直接贴已经跑通的代码private registerUserTools() { this.server.tool( getUserProfile, { userId: z.number().int().positive().describe(用户ID正整数), }, async ({ userId }) { const user await this.userService.findById(userId); if (!user) { return { content: [{ type: text as const, text: 未找到用户ID: ${userId} }], }; } return { content: [ { type: text as const, text: JSON.stringify({ id: user.id, name: user.name, email: user.email, createdAt: user.createdAt, }), }, ], }; }, ); }看这段代码的时候我建议你重点理解三处。第一tool()方法的第一个参数是工具名这个名字会直接暴露给AI去理解所以要见名知意不要用缩写和难懂的代号。第二个参数是zod对象描述工具入参的结构。第三个参数是实际执行函数接收一个对象参数解构出userId去查数据。第二zod schema里每个字段最好都加.describe()描述甚至可以在描述里写清楚取值范围、单位这类信息。AI模型在调用工具时主要靠这些描述来理解参数该怎么传描述越清晰工具被正确调用的概率越高。这是我实际测试下来的体会——不是模型笨而是它没有业务背景完全靠描述在推理你给它越详细的线索它行为就越准。第三返回值得符合MCP协议的content格式。最简单的就是text类型把结果转成字符串返回。如果你的数据是结构化的用JSON.stringify转一下AI拿到之后自己会解析理解。这里有个小细节返回的content数组里type一定要加as constTypeScript类型推导才不出问题否则会报类型不匹配。再注册一个带聚合逻辑的工具比如订单统计private registerOrderTools() { this.server.tool( getUserOrderStats, { userId: z.number().int().positive().describe(用户ID), startDate: z.string().optional().describe(开始日期格式YYYY-MM-DD), endDate: z.string().optional().describe(结束日期格式YYYY-MM-DD), }, async ({ userId, startDate, endDate }) { const stats await this.orderService.getStats(userId, startDate, endDate); return { content: [{ type: text as const, text: JSON.stringify(stats) }], }; }, ); }这里演示了带可选参数的场景。可选项在zod里用.optional()标识AI模型在调用的时候如果用户没提供日期它会自动省略这两个参数不会硬传空字符串或者其他奇怪的占位值。这种设计符合真实业务场景因为用户本来就是想到啥问啥你不能要求AI每次都把所有参数补齐。3.4 注册Resource和Prompt补齐能力除了工具MCP还支持Resource和Prompt。Resource一般用来给AI提供固定的参考数据比如服务的操作说明文档。我简单写一个例子private registerResources() { this.server.resource( system-help, help://system, async (uri) ({ contents: [ { uri: uri.href, text: [ 本MCP服务提供用户查询、订单统计能力。, 可以询问查询用户信息、查看用户订单统计等。, ].join(\n), }, ], }), ); }Resource的注册方式比Tool更简洁核心是给一个URI然后提供一个回调回调里返回文档内容。AI在需要理解这个服务能做什么的时候会主动去读取Resource相当于给模型一份服务说明书。Prompt这边如果你的业务有固定的话术模板比如帮我总结这个用户的消费情况可以注册成Prompt模板this.server.prompt( user-consumption-summary, { userId: z.number().int() }, ({ userId }) ({ messages: [ { role: user, content: { type: text, text: 请查询用户 ${userId} 的基本信息、订单统计并给出消费行为总结。, }, }, ], }), );这个能力在业务里很好用尤其是面对重复性较强的分析类请求模板能保证AI每次拿到的问题表述一致输出结果也更稳定。不过说实话日常开发中工具用得最多Resource和Prompt属于锦上添花的能力你可以按需添加。4. 业务整合与工程化细节4.1 把MCP模块接进NestJS启动流程模块写完之后需要把MCP模块注册到应用里。mcp.module.ts的代码很标准import { Module } from nestjs/common; import { McpService } from ./mcp.service; import { UserModule } from ../user/user.module; import { OrderModule } from ../order/order.module; Module({ imports: [UserModule, OrderModule], providers: [McpService], }) export class McpModule {}然后在app.module.ts里引入MCP模块import { Module } from nestjs/common; import { McpModule } from ./mcp/mcp.module; import { UserModule } from ./user/user.module; import { OrderModule } from ./order/order.module; Module({ imports: [McpModule, UserModule, OrderModule], }) export class AppModule {}main.ts的启动方式需要注意一下。如果是纯stdio模式用createApplicationContext而不是create因为它不需要HTTP服务import { NestFactory } from nestjs/core; import { AppModule } from ./app.module; async function bootstrap() { const app await NestFactory.createApplicationContext(AppModule); // 纯stdio模式下不需要 app.listen() } bootstrap();如果你后面要切SSE模式再把createApplicationContext换成create然后启动HTTP监听async function bootstrap() { const app await NestFactory.create(AppModule); await app.listen(3001); } bootstrap();这两种启动方式我都实际跑过切换成本很低所以建议一开始就按模块化的方式拆分后面改部署形态不伤筋动骨。4.2 日志输出的坑stdout被污染这是我在MCP开发中踩过最深的坑必须单独拿出来讲。stdio模式下MCP协议的数据就是通过标准输入输出stdin和stdout来传输的而NestJS默认的Logger以及console.log默认都是往stdout写数据的。一旦你的服务里有任何日志打印到了stdout它就会混进MCP的通信数据里导致客户端解析失败表现就是握手超时、工具调用无响应。解决方案是自定义日志输出把日志全部重定向到stderr。stderr和stdout在进程层面是分开的MCP协议不会管stderr所以日志打到stderr是完全安全的。我在项目里写了一个简单的LoggerServiceimport { LoggerService } from nestjs/common; export class StderrLogger implements LoggerService { log(message: any, context?: string) { process.stderr.write([LOG] ${context || } ${message}\n); } error(message: any, trace?: string, context?: string) { process.stderr.write([ERROR] ${context || } ${message} ${trace || }\n); } warn(message: any, context?: string) { process.stderr.write([WARN] ${context || } ${message}\n); } debug?(message: any, context?: string) { process.stderr.write([DEBUG] ${context || } ${message}\n); } verbose?(message: any, context?: string) { process.stderr.write([VERBOSE] ${context || } ${message}\n); } }然后在启动时注入const app await NestFactory.createApplicationContext(AppModule, { logger: new StderrLogger(), });另外还要注意你的业务代码里如果有第三方的console.log调用也要一并排查清理。这个坑的隐蔽之处在于本地跑单个工具可能不报错一旦工具调用频繁、并发上来stdout里的日志变多问题就会间歇性出现特别难排查。从第一天就养成日志走stderr的习惯能帮你省下大量排查时间。4.3 工具内部的错误处理策略MCP工具的执行函数里错误处理建议采用返回错误文本而不是直接抛异常的策略。因为异常一旦抛出协议层面的响应就会中断AI收到的是连接错误或者空响应它没法理解到底发生了什么。而把错误信息作为普通文本返回AI就能读到失败原因可以自己调整参数重新调用或者把错误转述给用户。我在实际项目里的做法是把业务查询包一层try-catchasync ({ userId }) { try { const user await this.userService.findById(userId); if (!user) { return { content: [{ type: text as const, text: 未找到用户ID: ${userId} }], }; } return { content: [{ type: text as const, text: JSON.stringify(user) }], }; } catch (err) { return { content: [ { type: text as const, text: 查询用户信息失败: ${err.message}, }, ], }; } }这样的设计有几个好处。第一AI能拿到明确的错误描述不会一脸茫然。第二日志系统能记录到完整的错误信息方便你排查。第三即便底层数据库偶发抖动MCP服务本身不会崩溃整个通信链路是稳定的。这个策略在对外提供MCP服务给团队其他人用的时候尤其重要因为调用方不会看到你的堆栈信息只有错误文本能传递出去。4.4 编译配置与运行产物这是我自己反复踩过的第二个大坑。MCP SDK的1.x版本是标准的ESM模块package.json里type: module这种设置会直接影响编译产物。NestJS默认的编译目标支持CommonJS但当你引入MCP SDK后如果tsconfig.json里的module配置不对编译出来的dist代码会因为ESM和CommonJS的模块兼容问题报错。我的建议是创建NestJS项目后不要改动默认的module配置直接沿用tsconfig.json和nest-cli.json的默认设置。然后编译时用标准的命令nest build编译完检查一下dist/main.js是否存在然后本地先用node直接跑一下node dist/main.js如果这一步不报错说明模块解析没问题。如果报了ERR_REQUIRE_ESM或者类似错误去检查一下tsconfig.json是否存在冲突设置或者考虑把tsconfig的module字段调整为nodenext。这个问题在Windows和Linux上的表现还可能不一样所以尽量在目标运行环境下多测一次。5. 客户端配置与联调验证5.1 用MCP Inspector做本地调试MCP官方提供了Inspector调试工具这是验证服务是否正常的首选方式。在项目根目录执行npx modelcontextprotocol/inspector node dist/main.js启动后在浏览器里打开它的界面能看到已注册的工具列表、Resource列表和Prompt列表。界面左侧是协议会话区右侧是工具调用区。你可以在工具调用区选中一个工具填上参数直接发起调用就能看到返回的content内容。整个过程不需要接AI客户端纯粹验证协议层和服务逻辑排查起来非常方便。我用Inspector的频率很高每次新增一个工具我都会先在这里调一遍确认参数和返回值格式没问题再去接真正的AI客户端。这样做的好处是问题隔离——如果Inspector里能通说明服务本身是好的问题大概率出在客户端配置上如果Inspector里就不通那就可以安心查服务代码了。5.2 配置到Claude Desktop等客户端服务本地跑通之后就可以配置到AI客户端里实际使用了。以Claude Desktop为例配置文件在客户端的设置目录下你需要把MCP server注册到配置里{ mcpServers: { nest-mcp: { command: node, args: [/absolute/path/to/dist/main.js] } } }这里要注意两点。第一args里的路径必须是绝对路径相对路径在客户端的工作目录不确定时经常出问题。第二如果你的项目里用到了pnpm本地跑脚本时可能要走pnpm dlx或者先构建成单文件然后直接用node执行dist里的产物这样最稳定。我之前图省事直接在args里填了nest start或者ts-node这类命令结果客户端环境根本找不到对应的可执行文件白折腾了好久。配置好之后重启客户端在对话里问一句帮我查一下用户ID为1的信息正常情况下客户端会先请求工具列表然后根据你的问题自动匹配并调用getUserProfile这个工具把返回结果组织成自然语言回复你。5.3 SSE模式与远程部署如果MCP服务要给团队其他人使用或者部署到远程服务器就需要SSE模式。SSE模式的实现其实不复杂MCP SDK里有现成的SSEServerTransport你只需要在NestJS里暴露两个HTTP接口一个用于创建SSE连接一个用于客户端通过POST发送消息回传。我这边在实际项目中是这样接的在MCP模块里加一个Controllerimport { Controller, Post, Req, Sse, MessageEvent, Res } from nestjs/common; import { McpService } from ./mcp.service; Controller(mcp) export class McpController { constructor(private readonly mcpService: McpService) {} Sse(sse) sse() { return this.mcpService.connectSSE(); } Post(messages) async messages(Req() req, Res() res) { await this.mcpService.handleSSEMessage(req, res); } }SSE方式的服务端实现要先维护一个客户端连接的映射关系然后在创建连接的时候初始化SSEServerTransport消息接口的路由参数里要带上sessionId这样才能准确把消息路由到对应的连接上。这块代码量会比stdio模式多一些但思路并不复杂核心就是把之前单向的stdio换成了HTTP长连接加上POST消息回传两个通道。如果你只想在本机用我建议直接用stdio省事又稳定。要远程部署再考虑SSE不要一上来就追求复杂方案。5.4 验证工具调用的完整链路当你第一次在客户端成功触发MCP工具调用完整的链路大致是这样的客户端启动时发initialize握手请求服务端返回server信息客户端请求工具列表然后在你发起对话时AI模型根据语义决定调用哪个工具构造好参数后请求服务端执行服务端跑完业务逻辑返回结构化结果AI再把这些结果组织成自然语言回复。我建议你在服务端日志里确认几个关键节点握手完成、收到工具列表请求、收到工具调用请求、执行完成。这四个节点只要都能看到就说明链路是通的。如果哪个节点缺失顺着对应位置排查即可。日志里建议记录工具名、入参、耗时这三个信息后面做性能分析和问题定位都用得上。6. 常见问题与排查技巧实录6.1 问题速查表这些是我自己在不同项目里实际遇到并排查过的问题整理成一张速查表方便你对照现象可能原因解决方案客户端连接时握手超时Node版本过低或模块兼容问题升级Node到18检查tsconfig的module配置工具列表能加载调用却无响应stdout被日志污染所有日志重定向到stderr清理console.log编译后运行报ERR_REQUIRE_ESMESM/CommonJS模块冲突检查package.json的type字段和tsconfig的module字段工具参数传错导致AI反复调用zod描述不够清晰每个字段补充describe写清格式和取值范围返回值在客户端显示为乱码编码不一致确保源码和终端都使用UTF-8编码服务在本地正常部署到服务器启动失败路径写死或env配置不对路径改用相对当前工作目录的方式配置抽成环境变量SSE模式连接一直pending没维护sessionId映射检查消息回传路由是否带上了sessionId参数6.2 工具命名与描述的经验之谈工具注册时工具名和参数描述直接决定了AI能不能正确使用你的服务。我见过不少同事把工具命名为getData“handleRequest”这种极其抽象的名字结果AI模型完全不知道这个工具该什么时候调用经常在错误场景触发或者干脆不触发。我的建议是工具名遵循动词业务对象的结构比如getUserProfile、createOrder、queryOrderStats让人和AI都能一眼看懂。参数描述里写清楚格式、范围、单位比如日期参数就写格式YYYY-MM-DD金额参数就写单位是分还是单位是元。这些细节平时看着不起眼但在实际使用中能显著提升AI调用的准确率。另外建议每次只注册你真的需要暴露给AI的能力不要把所有Service都无脑注册成工具。暴露的工具越多AI在选择时就越容易出错而且也会增加客户端每次请求工具列表的时延。保持精简做减法。6.3 优雅关闭与其他注意事项MCP服务的关闭过程也要处理干净。在stdio模式下客户端退出时会断开连接服务端的close方法会被触发但如果你是在NestJS进程里跑的服务进程退出时如果没有关闭MCP Server可能出现端口残留或者连接未释放的情况。所以一定要在onModuleDestroy里调用server.close()这个我在前面的代码里已经写上了属于必备项。还有一个容易被忽略的点是超时控制。MCP工具执行函数如果耗时太长客户端那边可能已经等得不耐烦断开了连接但服务端还在跑业务逻辑结果就是把计算浪费在一个无人接收响应的请求上。我做了一个通用的超时包装给所有工具的执行逻辑加了一个30秒的兜底超时超过就直接返回超时错误。具体超时时间按你业务情况调整但一定要有否则遇到慢查询时整个服务体验会非常糟糕。6.4 从简单到复杂我的推进路线建议最后分享一个我个人推荐的推进路线尤其适合刚接触MCP的团队。第一步先做一个只包含一个简单工具的stdio服务用MCP Inspector调通把协议层跑通第二步接一个真实业务数据源把查询工具换成真实的数据库查询验证链路第三步增加SSE模式让团队其他人能远程访问第四步再接Resource和Prompt丰富服务能力。每一步都有明确的验证标准而且每步之间的改动量都不大出了问题好定位。我见过不少团队一上来就想把所有能力全部铺开结果工具和资源一大堆连基础链路都没调通最后排查起来头都要炸了。MCP这个生态还在快速迭代踏踏实实把基础打牢比追新花样重要得多。回到开头那个问题用NestJS创建MCP服务到底值不值我的答案很明确如果你的MCP服务只是临时玩一下裸Node完全是够的但如果你要做的是真正接业务、长期维护的服务NestJS这套工程化能力绝对值得投入。上头说的日志坑、模块划分、错误处理策略都是我实际跑业务时候一点点磨出来的经验照着排布下来你大概率能绕开我踩过的那些弯路。