ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于MCP协议将.NET接口暴露给AI调用的工程实践

基于MCP协议将.NET接口暴露给AI调用的工程实践 1. 为什么我要把 .NET 接口直接交给 AI 调用先说结论MCP 不是又一个AI 插件协议的营销词它真正解决的是一个很具体的工程问题——让大模型在运行时动态发现并调用你已有的后端接口而不是把接口文档复制粘贴到提示词里。我在一个内部管理系统上做过对比。系统有 40 多个 ASP.NET Core 接口涵盖订单查询、库存调整、报表导出。之前团队用把 Swagger JSON 塞进上下文的方式让 AI 帮忙做操作助手结果很尴尬Swagger 文档动辄几千行token 消耗巨大模型还经常把POST /api/order/cancel和POST /api/order/close搞混参数结构记串。更麻烦的是接口一改提示词就得同步改维护成本比写代码还高。MCPModel Context Protocol的思路完全不同。它把工具抽象成一个标准化的服务端AI 客户端在启动时通过协议握手动态拉取当前可用的工具列表每个工具带名称、描述、JSON Schema 参数定义需要调用时再发起一次结构化请求。你的 .NET 接口只需要被包装成 MCP 的 toolAI 就能像调用本地函数一样调用它。接口改了工具描述跟着改客户端下次握手自动拿到最新定义不需要动提示词。这套东西适合谁我认为有三类人值得花时间落地后端开发者手里有一堆 ASP.NET Core 接口想让 AI Agent 或桌面 AI 客户端直接操作业务系统而不是靠人肉点按钮。AI 应用开发者在搭 Agent 工作流需要把企业内部能力数据库查询、文件处理、业务 API暴露给模型MCP 提供了一层比 function calling 更规范的中间层。技术负责人在评估AI 怎么接入现有系统这件事想找一个不侵入业务代码、可灰度、可审计的方案。下面我按先跑通、再讲透、最后避坑的顺序把服务端和客户端两侧的落地过程完整写一遍。所有代码基于 .NET 8 ASP.NET CoreMCP 的 .NET SDK 目前主流做法是引入官方或社区的 MCP 服务端库配合ModelContextProtocol命名空间下的类型。如果你用的是 .NET Framework思路一样但依赖注入和托管模型的写法要换成对应的老式写法后面我会单独提。2. MCP 服务端把 ASP.NET Core 接口包装成 AI 可调用的工具2.1 先搞清楚 MCP 服务端到底要暴露什么很多人一上来就想着把我所有接口都变成 MCP 工具这是第一个坑。MCP 服务端的核心不是接口转发而是工具契约的设计。一个 MCP tool 包含三部分组成作用对应到 .NET工具名AI 用来选择调用哪个工具的标识方法名或显式声明的字符串工具描述给模型看的自然语言说明决定它会不会选对XML 注释或特性上的 Description参数 Schema模型生成参数时的约束方法参数类型自动推导出的 JSON Schema关键点在第二行。模型选工具靠的是描述不是代码逻辑。我见过有人把工具描述写成查询订单结果模型在查询订单和查询订单详情之间反复横跳。正确做法是把业务语义写进去比如根据订单号查询订单的当前状态和金额不返回商品明细适合快速确认订单是否存在。所以服务端设计的第一步是筛选哪些接口值得暴露。我的筛选标准是三条幂等或可安全重试的读操作优先写操作必须带明确的确认语义。参数结构简单最好不超过 5 个必填参数复杂对象拆成多个工具。单个工具的执行时间可控超过 10 秒的操作要么异步化要么在描述里明确告知模型这是长任务。2.2 项目结构与依赖引入我用的项目结构是这样的和普通 Web API 没本质区别只是多了一个 MCP 托管层OrderMcpServer/ Program.cs Tools/ OrderTools.cs InventoryTools.cs Services/ IOrderService.cs OrderService.cs appsettings.json依赖方面核心是引入 MCP 服务端库。以目前 .NET 生态的常见做法在.csproj里加PackageReference IncludeModelContextProtocol Version0.1.* / PackageReference IncludeModelContextProtocol.AspNetCore Version0.1.* /注意MCP 的 .NET SDK 还在快速迭代版本号和命名空间可能随版本变化。落地时以你实际拉到的包为准不要照抄版本号。如果包名对不上去 NuGet 搜ModelContextProtocol看最新稳定版。Program.cs里注册服务和 MCP 端点var builder WebApplication.CreateBuilder(args); builder.Services.AddScopedIOrderService, OrderService(); builder.Services.AddScopedInventoryService(); // 注册 MCP 服务端扫描程序集中的工具类 builder.Services .AddMcpServer() .WithToolsFromAssembly(); var app builder.Build(); // 把 MCP 挂到一个独立路径避免和现有 API 冲突 app.MapMcp(/mcp); app.Run();这里有个细节值得说为什么用WithToolsFromAssembly而不是手动一个个注册。手动注册在工具少的时候清晰但工具一多注册代码和工具实现分离改一个忘一个。程序集扫描把工具类作为唯一事实来源工具类里加了方法就自动生效减少不一致。代价是启动时多一点点反射开销对服务端来说可以忽略。2.3 写第一个工具从订单查询开始工具类的写法核心是用特性标注哪些方法暴露为工具。下面是我实际用的订单查询工具using System.ComponentModel; using ModelContextProtocol.Server; [McpServerToolType] public class OrderTools { private readonly IOrderService _orderService; public OrderTools(IOrderService orderService) { _orderService orderService; } [McpServerTool, Description( 根据订单号查询订单的当前状态和金额。 只返回状态、金额、下单时间不返回商品明细。 适合快速确认订单是否存在以及是否已支付。)] public async TaskOrderSummary GetOrderSummary( [Description(订单号格式为 ORD 开头加 12 位数字例如 ORD202401150001)] string orderId) { var order await _orderService.GetByIdAsync(orderId); if (order is null) { throw new McpException($订单 {orderId} 不存在); } return new OrderSummary { OrderId order.Id, Status order.Status.ToString(), Amount order.Amount, CreatedAt order.CreatedAt }; } }这段代码里有三个经验点都是踩过坑才明白的第一参数描述必须写格式示例。我一开始只写订单号模型有时候传12345有时候传ORD-2024-001格式五花八门。加上格式为 ORD 开头加 12 位数字例如 ORD202401150001之后参数正确率从大概六成提到九成以上。模型对示例的敏感度远高于对规则的描述。第二异常要用 MCP 认识的异常类型。直接throw new Exception会让客户端拿到一个不友好的错误模型也不知道该怎么处理。用McpException并带上人类可读的消息模型能理解订单不存在并据此回复用户而不是报一个内部错误。第三返回对象要精简。我最初直接返回完整的 Order 实体包含几十个字段结果模型在回复里把内部字段名都念出来了用户体验很差。改成专门的OrderSummaryDTO 之后输出干净很多。工具返回什么模型就可能说什么这句话值得贴在显示器上。2.4 写操作工具库存调整的确认语义设计写操作是 MCP 落地里最容易出事的地方。模型一旦误判可能直接改了生产数据。我的做法是把写操作拆成预检 执行两步让模型有机会在中间确认。[McpServerToolType] public class InventoryTools { private readonly InventoryService _inventory; public InventoryTools(InventoryService inventory) { _inventory inventory; } [McpServerTool, Description( 预检库存调整操作不实际修改数据。 返回调整前后的库存数量用于向用户确认。 确认后再调用 ApplyStockAdjustment。)] public async TaskStockPreview PreviewStockAdjustment( [Description(商品 SKU例如 SKU-10023)] string sku, [Description(调整数量正数为入库负数为出库)] int delta) { var current await _inventory.GetStockAsync(sku); return new StockPreview { Sku sku, Before current, After current delta, WillGoNegative current delta 0 }; } [McpServerTool, Description( 执行库存调整。调用前应先用 PreviewStockAdjustment 预检并向用户确认。 如果预检结果显示 WillGoNegative 为 true不要执行。)] public async TaskStockResult ApplyStockAdjustment( [Description(商品 SKU)] string sku, [Description(调整数量正数入库负数出库)] int delta) { var result await _inventory.AdjustAsync(sku, delta); return new StockResult { Sku sku, NewStock result.NewStock, Success result.Success }; } }这套设计的关键在于用工具描述引导模型的工作流。ApplyStockAdjustment的描述里明确写了调用前应先用 PreviewStockAdjustment 预检并向用户确认模型在多数情况下会遵循这个流程。这不是强约束但实测下来配合良好的系统提示词误操作率能压到很低。如果你需要更强的约束可以在服务端加一层校验ApplyStockAdjustment内部检查是否在最近 N 秒内有过对应的预检记录没有就拒绝。这就把软引导变成了硬约束。代价是要维护预检状态适合对数据安全要求极高的场景。2.5 和现有 Swagger 接口共存的处理大部分项目不是从零开始而是已经有一堆 Swagger 接口。我的建议是不要试图把 Swagger 自动转成 MCP 工具至少第一版不要。原因有两个一是 Swagger 里的接口粒度是资源操作MCP 工具的粒度应该是业务意图。一个PATCH /api/order/{id}可能对应取消订单关闭订单修改地址三种业务意图自动转换会把它们混成一个工具模型根本选不对。二是 Swagger 的参数模型往往包含大量内部字段直接暴露给模型既浪费 token 又容易误导。我的做法是手工挑选 手工包装。MCP 工具层调用现有的 Service 层Service 层再调数据库或内部 API。这样 MCP 层是薄薄的一层适配业务逻辑不重复接口变更时只需要调整工具描述。如果确实想减少手工量可以写一个代码生成器读取 Swagger JSON按 tag 分组生成工具类骨架然后人工补描述和精简参数。我试过这个路子生成骨架能省大概三成工作量但描述和参数精简还是得人来因为那部分恰恰是模型能不能用对的关键。2.6 本地调试怎么确认服务端真的通了服务端起起来之后别急着接 AI 客户端。先用一个简单的 HTTP 请求确认 MCP 端点活着。MCP 基于 JSON-RPC握手阶段会有一个initialize请求。你可以用 curl 或者 Postman 发一个curl -X POST https://localhost:8889/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2024-11-05, capabilities: {}, clientInfo: { name: test, version: 1.0 } } }如果返回里有serverInfo和capabilities说明服务端握手正常。接着发tools/list看工具列表curl -X POST https://localhost:8889/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:2,method:tools/list,params:{}}这一步能帮你排除掉一大半问题。我遇到过工具类没被扫描到的情况tools/list返回空数组最后发现是工具类所在的程序集没被WithToolsFromAssembly覆盖到——它默认扫的是入口程序集工具类如果放在单独的类库项目里需要显式指定程序集。提示本地开发用https://localhost:8889时如果遇到证书问题先执行dotnet dev-certs https --trust信任开发证书。生产环境务必换成正式证书MCP 客户端对 TLS 的要求和普通 HTTPS 服务一致。3. 客户端接入让 AI 真正把工具用起来3.1 客户端不是再写一个程序而是配置一个连接服务端跑通后客户端侧的工作量比很多人想象的小。MCP 的客户端通常是现成的 AI 应用桌面客户端、IDE 插件、Agent 框架你要做的是告诉它去哪里连、连什么。配置一般是一个 JSON 文件结构大致如下{ mcpServers: { order-system: { url: https://localhost:8889/mcp, transport: http } } }不同客户端的配置字段名可能不同有的用command启动本地进程stdio 传输有的用url连远程服务HTTP 传输。你的 .NET 服务端是 HTTP 服务所以走url这一路。这里有个容易忽略的点传输方式决定了部署形态。stdio 传输适合本地工具进程随客户端启动HTTP 传输适合常驻服务多个客户端可以共享。你的 ASP.NET Core 服务端天然是常驻的所以 HTTP 传输更合适也方便做鉴权和审计。3.2 连接建立后发生了什么理解握手过程对排查问题很有帮助。客户端连接时大致经历这几步客户端发initialize带上自己支持的协议版本和能力。服务端回initialize响应带上服务端信息和能力。客户端发notifications/initialized确认。客户端发tools/list拉取工具清单。之后每次调用走tools/call带上工具名和参数。知道这个顺序你就能定位问题出在哪一环。比如客户端显示已连接但看不到工具那问题在第四步多半是tools/list返回空或者格式不对。如果连接直接失败问题在第一二步检查 URL、证书、协议版本。3.3 鉴权别让 MCP 端点裸奔本地开发可以不管鉴权但只要服务端能被外部访问就必须加。MCP 走 HTTP所以标准的 ASP.NET Core 鉴权中间件都能用。我一般用 API Key 或 Bearer Token简单直接builder.Services.AddAuthentication(Bearer) .AddJwtBearer(Bearer, options { options.Authority builder.Configuration[Auth:Authority]; options.TokenValidationParameters new TokenValidationParameters { ValidateAudience true, ValidAudience builder.Configuration[Auth:Audience] }; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapMcp(/mcp).RequireAuthorization();客户端侧在配置里带上 token{ mcpServers: { order-system: { url: https://your-host/mcp, transport: http, headers: { Authorization: Bearer your-token } } } }注意token 不要硬编码在会提交到版本库的配置文件里。用环境变量或客户端支持的密钥引用机制。我见过有人把生产 token 提交到公开仓库后果不用多说。3.4 工具权限分级不同客户端给不同工具一个服务端可能同时服务多个客户端比如内部管理助手需要全部工具而面向普通用户的助手只能查不能改。MCP 本身没有内置的工具级权限但可以在服务端做。思路是根据认证身份过滤tools/list的返回。实现上可以在工具类上加一个自定义特性标注所需角色然后在tools/list的处理管道里过滤。或者更简单粗暴把读写工具拆成两个 MCP 端点/mcp/readonly和/mcp/full各自注册不同的工具集客户端按需连接。后者实现简单运维清晰我倾向于这种。// 只读端点 app.MapMcp(/mcp/readonly) .WithToolsOrderQueryTools() .WithToolsInventoryQueryTools() .RequireAuthorization(ReadOnly); // 完整端点 app.MapMcp(/mcp/full) .WithToolsFromAssembly() .RequireAuthorization(FullAccess);3.5 实测一次完整的 AI 调用链路配置好之后我在客户端里输入帮我看看 ORD202401150001 这个订单现在什么状态。实际发生的过程是客户端把用户输入和工具清单一起发给模型。模型判断需要调用GetOrderSummary生成参数{orderId: ORD202401150001}。客户端发tools/call到服务端。服务端执行OrderTools.GetOrderSummary查数据库返回OrderSummary。客户端把结果回填给模型。模型生成自然语言回复订单 ORD202401150001 当前状态为已支付金额 1280 元下单时间是 2024 年 1 月 15 日。整个链路里模型只负责选工具、填参数、组织语言业务逻辑全在你的 .NET 代码里。这就是 MCP 的价值——AI 负责理解意图你的系统负责执行边界清晰。4. 踩坑记录那些文档里不会写的细节4.1 工具描述写得太技术模型选不对我最初的工具描述是Get order by id结果模型在中文对话里经常不选它因为它觉得这是个英文技术接口。改成中文业务描述后立刻正常。描述的语言要和用户对话的语言一致模型对语言匹配很敏感。另一个坑是描述里堆砌技术术语。比如调用 OrderService.GetByIdAsync 方法查询模型完全不需要知道你的类名和方法名这些信息只会干扰它。描述应该站在业务角度写回答这个工具能帮用户做什么。4.2 参数类型用复杂对象模型填不对我试过让工具接收一个OrderQueryRequest对象里面有 8 个可选字段。结果模型要么全填要么全不填很少能正确使用可选字段。后来拆成多个工具每个工具参数不超过 3 个正确率大幅提升。MCP 工具的参数设计原则和 REST API 不一样。REST 追求通用和复用MCP 工具追求一个工具对应一个明确意图。宁可工具多几个也不要参数复杂。4.3 返回数据太大把上下文撑爆有个报表工具返回了几千行数据模型收到后直接超了上下文限制整个对话崩掉。解决办法是在工具内部做分页和截断返回时带上共 N 条已返回前 M 条的提示让模型知道数据不完整需要时可以再调。[McpServerTool, Description(查询订单列表最多返回 20 条超出请用分页参数)] public async TaskPagedResultOrderSummary ListOrders( [Description(页码从 1 开始)] int page 1, [Description(每页条数最大 20)] int pageSize 20) { pageSize Math.Min(pageSize, 20); var (items, total) await _orderService.ListAsync(page, pageSize); return new PagedResultOrderSummary { Items items, Total total, Page page, PageSize pageSize }; }4.4 超时和重试模型不知道你的接口有多慢MCP 客户端一般有默认超时超过就断开。如果你的接口要跑 30 秒客户端可能 10 秒就放弃了。两个办法一是把长任务改成异步工具立即返回一个任务 ID再提供查询任务状态的工具二是在客户端配置里调大超时。前者更通用后者更省事看场景选。重试要特别小心。读操作重试没问题写操作重试可能造成重复扣减。我的做法是给写操作加幂等键工具参数里带一个requestId服务端记录已处理的 requestId重复请求直接返回上次结果。4.5 本地 HTTPS 证书导致的连接失败开发阶段最常见的报错就是证书问题。客户端连https://localhost:8889/mcp时报证书不受信任。解决步骤dotnet dev-certs https --clean清理旧证书。dotnet dev-certs https --trust重新生成并信任。重启服务端和客户端。如果客户端是独立进程它可能不读系统的证书信任列表需要在客户端配置里显式指定跳过证书校验仅限本地开发。生产环境绝对不要跳过。4.6 .NET Framework 项目的适配思路如果你的接口跑在 .NET Framework 上MCP 的官方 SDK 可能不支持。这时候有两个选择一是用 .NET 8 写一个 MCP 网关通过 HTTP 调用你现有的 Framework 接口二是找社区的非官方实现。我推荐第一种。网关模式的好处是隔离——MCP 层用新框架业务层保持不动升级风险最小。网关里就是普通的HttpClient调用把 Framework 接口的响应转成 MCP 工具返回。[McpServerTool, Description(查询订单状态内部调用旧系统接口)] public async TaskOrderSummary GetOrderSummary(string orderId) { var response await _httpClient.GetAsync( $https://legacy-system/api/order/{orderId}); response.EnsureSuccessStatusCode(); var legacy await response.Content.ReadFromJsonAsyncLegacyOrder(); return new OrderSummary { /* 字段映射 */ }; }5. 上线前必须想清楚的几件事5.1 审计日志AI 调了什么必须留痕AI 调用和人工点击不一样出问题时你很难复现当时模型为什么这么选。所以审计日志是必须的。我记录的内容包括时间、客户端标识、工具名、参数、返回摘要、耗时、是否成功。参数里的敏感字段如手机号做脱敏。ASP.NET Core 里可以用中间件统一记录也可以在工具基类里做。我倾向于中间件因为能覆盖所有工具不会漏。app.Use(async (context, next) { if (context.Request.Path.StartsWithSegments(/mcp)) { var sw Stopwatch.StartNew(); await next(); sw.Stop(); _logger.LogInformation( MCP request {Path} completed in {Elapsed}ms with {Status}, context.Request.Path, sw.ElapsedMilliseconds, context.Response.StatusCode); } else { await next(); } });5.2 限流防止模型陷入循环疯狂调用模型有时候会陷入调用-不满意-再调用的循环。我遇到过模型连续调用同一个查询工具十几次的情况。服务端加限流能兜底。ASP.NET Core 自带限流中间件builder.Services.AddRateLimiter(options { options.AddFixedWindowLimiter(mcp, opt { opt.Window TimeSpan.FromMinutes(1); opt.PermitLimit 60; }); }); app.UseRateLimiter(); app.MapMcp(/mcp).RequireRateLimiting(mcp);每分钟 60 次对正常使用足够对异常循环能起到刹车作用。5.3 灰度先只读再写操作上线节奏我建议分三步第一步只暴露读工具观察一段时间确认模型选得准、参数填得对第二步开放低风险的写操作如修改备注继续观察第三步才开放高风险操作如库存调整、订单取消。每一步之间至少留一周收集审计日志看有没有异常调用模式。直接全量开放写操作风险太大。5.4 工具版本管理接口改了怎么办工具描述和参数是模型行为的依据改了描述可能改变模型的选择。所以工具变更要当 API 变更对待改描述、加参数、删工具都要记录变更日志必要时通知使用方。我的做法是在工具描述里带一个版本标记比如描述末尾加v22024-06 更新方便排查问题时确认客户端用的是哪个版本。这不是标准做法但实用。6. 我在这套方案里最看重的三个设计取舍第一个取舍是工具粒度宁细勿粗。前面反复提到MCP 工具是给模型选的不是给人调的。人看文档能理解一个复杂接口的多种用法模型不行。把复杂接口拆成多个意图明确的工具虽然工具数量多了但模型的选择准确率会高很多。我现在的项目里40 多个接口最终拆成了 60 多个工具看起来冗余实际用起来顺畅。第二个取舍是业务逻辑不放进工具层。工具层只做参数适配、调用 Service、结果裁剪。这样业务逻辑只有一份MCP 只是多了一个入口。如果哪天 MCP 协议变了或者不用了删掉工具层就行业务代码零改动。这个隔离在技术选型快速变化的当下特别重要。第三个取舍是默认只读写操作显式开启。这不是技术问题是风险控制问题。AI 的能力边界在快速变化今天表现好的模型明天可能因为一次更新行为改变。把危险操作的开关握在自己手里比事后补救划算得多。这套方案我在两个内部系统上跑了大半年日常使用没出过数据事故模型选工具的准确率在良好描述下能到九成以上。剩下的那一成主要靠预检机制和审计日志兜底。如果你正准备把现有 .NET 接口接给 AI建议从只读工具开始跑顺了再逐步放开别一上来就全量开放。
返回列表