1. 为什么我要把 .NET 接口直接交给 AI 调用
先说结论:MCP 不是又一个"AI 插件协议"的营销词,它真正解决的是一个很具体的工程问题——让大模型在运行时动态发现并调用你已有的后端接口,而不是把接口文档复制粘贴到提示词里。
我在一个内部管理系统上做过对比。系统有 40 多个 ASP.NET Core 接口,涵盖订单查询、库存调整、报表导出。之前团队用"把 Swagger JSON 塞进上下文"的方式让 AI 帮忙做操作助手,结果很尴尬:Swagger 文档动辄几千行,token 消耗巨大,模型还经常把POST /api/order/cancel和POST /api/order/close搞混,参数结构记串。更麻烦的是接口一改,提示词就得同步改,维护成本比写代码还高。
MCP(Model Context Protocol)的思路完全不同。它把"工具"抽象成一个标准化的服务端,AI 客户端在启动时通过协议握手,动态拉取当前可用的工具列表(每个工具带名称、描述、JSON Schema 参数定义),需要调用时再发起一次结构化请求。你的 .NET 接口只需要被包装成 MCP 的 tool,AI 就能像调用本地函数一样调用它。接口改了,工具描述跟着改,客户端下次握手自动拿到最新定义,不需要动提示词。
这套东西适合谁?我认为有三类人值得花时间落地:
- 后端开发者:手里有一堆 ASP.NET Core 接口,想让 AI Agent 或桌面 AI 客户端直接操作业务系统,而不是靠人肉点按钮。
- AI 应用开发者:在搭 Agent 工作流,需要把企业内部能力(数据库查询、文件处理、业务 API)暴露给模型,MCP 提供了一层比 function calling 更规范的中间层。
- 技术负责人:在评估"AI 怎么接入现有系统"这件事,想找一个不侵入业务代码、可灰度、可审计的方案。
下面我按"先跑通、再讲透、最后避坑"的顺序,把服务端和客户端两侧的落地过程完整写一遍。所有代码基于 .NET 8 + ASP.NET Core,MCP 的 .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 Include="ModelContextProtocol" Version="0.1.*" /> <PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.1.*" />注意:MCP 的 .NET SDK 还在快速迭代,版本号和命名空间可能随版本变化。落地时以你实际拉到的包为准,不要照抄版本号。如果包名对不上,去 NuGet 搜
ModelContextProtocol看最新稳定版。
Program.cs里注册服务和 MCP 端点:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddScoped<InventoryService>(); // 注册 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 Task<OrderSummary> 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 Task<StockPreview> 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 Task<StockResult> 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") .WithTools<OrderQueryTools>() .WithTools<InventoryQueryTools>() .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 Task<PagedResult<OrderSummary>> 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 PagedResult<OrderSummary> { 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 Task<OrderSummary> GetOrderSummary(string orderId) { var response = await _httpClient.GetAsync( $"https://legacy-system/api/order/{orderId}"); response.EnsureSuccessStatusCode(); var legacy = await response.Content.ReadFromJsonAsync<LegacyOrder>(); 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 变更对待:改描述、加参数、删工具,都要记录变更日志,必要时通知使用方。
我的做法是在工具描述里带一个版本标记,比如描述末尾加"(v2,2024-06 更新)",方便排查问题时确认客户端用的是哪个版本。这不是标准做法,但实用。
6. 我在这套方案里最看重的三个设计取舍
第一个取舍是工具粒度宁细勿粗。前面反复提到,MCP 工具是给模型选的,不是给人调的。人看文档能理解一个复杂接口的多种用法,模型不行。把复杂接口拆成多个意图明确的工具,虽然工具数量多了,但模型的选择准确率会高很多。我现在的项目里,40 多个接口最终拆成了 60 多个工具,看起来冗余,实际用起来顺畅。
第二个取舍是业务逻辑不放进工具层。工具层只做参数适配、调用 Service、结果裁剪。这样业务逻辑只有一份,MCP 只是多了一个入口。如果哪天 MCP 协议变了或者不用了,删掉工具层就行,业务代码零改动。这个隔离在技术选型快速变化的当下特别重要。
第三个取舍是默认只读,写操作显式开启。这不是技术问题,是风险控制问题。AI 的能力边界在快速变化,今天表现好的模型明天可能因为一次更新行为改变。把危险操作的开关握在自己手里,比事后补救划算得多。
这套方案我在两个内部系统上跑了大半年,日常使用没出过数据事故,模型选工具的准确率在良好描述下能到九成以上。剩下的那一成,主要靠预检机制和审计日志兜底。如果你正准备把现有 .NET 接口接给 AI,建议从只读工具开始,跑顺了再逐步放开,别一上来就全量开放。