前阵子 Claude 发布 MCP(Model Context Protocol)之后,整个 AI 圈都在讨论怎么让模型“长出手脚”。我自己的感受特别深:以前做 AI Agent,最头疼的就是让模型去调内部系统。写 Function Calling 的 JSON Schema 写得想吐,接口字段和模型参数之间全靠手搓映射,改一个字段要同步改好几处。MCP 出现之后,这套逻辑被标准化了,AI 客户端和工具服务端之间有了统一协议。更关键的是,.NET 这边跟进速度很快,官方 SDK 已经能用了,直接把现有的 ASP.NET Core 接口包成 MCP 服务,让 Claude、自研 Agent 去调。
这篇文章我打算从服务端和客户端两侧完整走一遍:先讲清楚 MCP 到底解决了什么问题,然后直接上代码,把一个真实的 .NET 业务接口改造成 MCP 工具,再写一个客户端去消费它。全程用 .NET 8 + 官方 ModelContextProtocol SDK,有踩坑的地方我也会标注出来,希望能帮你少走弯路。
1. MCP 到底改了什么:从“手写工具注册”到“标准协议”
1.1 没有 MCP 的时候,开发有多痛
在 MCP 之前,让大模型调用业务接口,最常见的方式是 Function Calling / Tool Calling。OpenAI、通义、文心各有各的格式,但本质都是:你把工具的 JSON Schema 提供给模型,模型在推理时决定“我要调用哪个工具、参数是什么”,然后你的代码接住这次调用,执行真实逻辑,把结果返回给模型,模型再基于结果生成自然语言回答。
听起来不复杂,但真正落地的时候问题一个接一个。最典型的是工具定义的重复劳动:一个订单查询接口,在 OpenAI 里要写一份 JSON Schema,换到 Claude 又要重新描述一遍参数;工具多了以后,参数格式不统一、命名不一致、类型对不上,维护成本直接爆炸。另一个痛点是“协议”是各家私有的,模型供应商绑定得很死,你要换个模型,整套工具注册逻辑就可能要重写。
还有更隐蔽的问题:工具返回的数据结构没有标准约束。有人返回纯文本,有人返回 JSON 字符串,有人返回二进制,模型拿到以后解析逻辑完全靠猜,出错率很高。MCP 出现以后,这些东西被收敛进一个协议里:工具的定义、调用、返回结果、错误处理,全部标准化。服务端只需要实现一份 MCP 接口,任何支持 MCP 的客户端都能用,不再区分 OpenAI 还是 Claude。
1.2 MCP 的核心模型:Client、Server、Transport
MCP 的架构非常像 C/S 架构,但比普通的 HTTP 接口多了一层“上下文”的概念。MCP Server 负责暴露能力,MCP Client 负责连接和调用,中间通过 Transport 通信,最常用的 Transport 是基于 HTTP 的 Streamable HTTP(早期是 SSE,现在官方推荐 Streamable HTTP)。
协议本身的底层是 JSON-RPC 2.0。这一点很关键,它意味着 MCP 不是一个“RESTful 风格”的接口,而是基于请求/响应和通知的 RPC。你可以把 MCP 理解成“给 AI 用的、带类型和描述信息的 RPC 网关”。Client 连接 Server 后,第一件事是initialize握手,然后通过tools/list拿到全部工具列表,之后调用某个工具就走tools/call。
这里我多说一句,MCP 里除了 Tool,还有 Resource 和 Prompt 两个概念。Resource 类似把文件、数据库记录暴露给模型去“读”,Prompt 是预定义的提示词模板。但现阶段 90% 的业务接入都是围绕 Tool 展开的,也就是“让 AI 能执行动作”。这篇文章也会把重点放在 Tool 上,Resource 和 Prompt 后文会简单提一下。
1.3 .NET 生态的 MCP 现状
微软官方维护了ModelContextProtocol这个 NuGet 包,最新的版本已经进入比较稳定的状态。它对 ASP.NET Core 的支持很完整,可以直接在现有的 Web 应用里挂载 MCP 端点,也可以单独起一个专门的 MCP Server 进程。客户端方面也有 SDK,不是只能靠 Python 或者 Node 写客户端,.NET 程序一样能作为 MCP 客户端去调外部服务。
我实测下来,.NET SDK 走的风格和 ASP.NET Core 高度一致:服务端用AddMcpServer()注册、MapMcp()映射路由,工具类绑定依赖注入。如果你写过 Minimal API,上手基本没有成本。下面就开始进入实战。
2. 服务端落地:把现有 .NET 接口包成 MCP 工具
2.1 极简 MCP Server:10 行代码先跑起来
先搞一个最小可运行的 MCP Server,确认链路是通的。新建一个 ASP.NET Core Web API 项目,空模板就行,然后安装两个包:
dotnet add package ModelContextProtocol.AspNetCore dotnet add package ModelContextProtocol我写这篇文章时用的是 0.2.x 版本的 SDK,API 命名和早期预览版有差异。你可以用dotnet add package直接装最新稳定版,示例代码我会尽量用版本差异较小的写法。
Program.cs里这样写:
using ModelContextProtocol; using ModelContextProtocol.Server; var builder = WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithHttpTransport(); builder.Services.AddSingleton<DateTimeService>(); builder.Services.AddMcpTool<DateTimeTools>(); var app = builder.Build(); app.MapMcp(); app.Run();DateTimeTools很简单:
public class DateTimeTools { [McpTool("get_current_time", Description = "获取服务器当前时间,返回 ISO 8601 格式字符串")] public string GetCurrentTime( [McpToolParameter(Description = "时区 ID,例如 Asia/Shanghai")] string timeZoneId) { var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz) .ToString("O"); } }跑起来以后,服务默认监听某个端口,MCP 端点路径是/mcp,直接访问http://localhost:5150/mcp。这一步跑通了,就说明服务端已经具备 MCP 能力,任何 MCP 客户端都能来连。
2.2 工具方法注册与依赖注入的用法
上面示例里有个关键点:AddMcpTool<T>把整个类里的[McpTool]方法全部注册成工具,而且这些工具方法支持构造函数依赖注入,类本身也会被注册进容器。这意味着你可以直接把仓储、DbContext、HttpClient 等现有服务塞进来,不用另起炉灶。
比如一个真实的订单服务:
public class OrderTools { private readonly OrderService _orderService; public OrderTools(OrderService orderService) { _orderService = orderService; } [McpTool("get_order_status", Description = "根据订单号查询订单状态")] public async Task<string> GetOrderStatus( [McpToolParameter(Description = "订单号,例如 ORD-20250101-001")] string orderNo, CancellationToken cancellationToken) { var order = await _orderService.FindByNoAsync(orderNo, cancellationToken); return order is null ? JsonSerializer.Serialize(new { code = 404, message = "订单不存在" }) : JsonSerializer.Serialize(order); } [McpTool("cancel_order", Description = "取消一个未发货的订单,返回操作结果")] public async Task<string> CancelOrder( [McpToolParameter(Description = "订单号")] string orderNo, [McpToolParameter(Description = "取消原因")] string reason, CancellationToken cancellationToken) { var result = await _orderService.CancelAsync(orderNo, reason, cancellationToken); return JsonSerializer.Serialize(result); } }这里我强烈建调用“返回统一 JSON 字符串”的约定,而不是直接返回一个自定义对象让 SDK 帮你序列化。原因有两个:第一,返回 JSON 字符串时,你自己完全掌控结构,可以方便地塞入 code、message、data 这类统一的响应外壳;第二,MCP 客户端拿到的是字符串,模型对 JSON 字符串的可读性远好于自定义二进制或纯文本,解析成功率更高。
2.3 用 MCP Inspector 验证服务端功能
服务端跑起来以后,怎么验证?官方提供了一个叫 Inspector 的可视化调试工具,按两个方式可以起(最新版本已经支持 npx 一键启动):
npx @modelcontextprotocol/inspector打开它给的本地地址,Inspector 里可以填服务端地址http://localhost:5150/mcp和传输类型(Streamable HTTP)。连上之后,左侧能看到工具列表、参数结构,右侧可以直接填参数调用工具,返回结果一目了然。我非常建议在写客户端之前先用 Inspector 把工具调通,因为排查“服务端问题”和“客户端问题”是两个完全不同的战场。
如果不方便用 npx,我也有一个粗暴但管用的笨办法:直接用 curl 模拟 JSON-RPC 请求。MCP 的 Streamable HTTP 端点可以接收 POST JSON,body 里带 JSON-RPC 消息,例如先发 initialize,再发 notifications/initialized,最后发 tools/list。不过这个流程有点繁琐,用来排查问题可以,日常开发还是 Inspector 更高效。
2.4 工具粒度设计的三个原则
工具调用是给大模型用的,和给人设计的 REST API 有很大区别。我把踩过的坑总结成三条原则。
第一,一个工具只做一件原子性的事情。“查询订单状态”和“取消订单”必须拆成两个工具,千万不要做成order_operation(orderNo, operationType)。模型在选参数的时候很容易把 operationType 拼错,而且工具语义不清晰会让模型更困惑。
第二,参数数量越少越好。能用一个字符串搞定的,不要拆成三个字段。参数越多,模型“幻觉参数”的概率越大。比如查询订单,orderNo一个字段就够;如果是带筛选条件的,可以接收一个 JSON 字符串参数,让模型自己根据自然语言生成 JSON。
第三,描述信息要写人话。Description不是给你自己看的,是给模型看的。你写“获取服务器当前时间”,模型就很清楚什么时候该调用它。你写“时间服务接口”,模型可能就蒙了。描述越口语化、越贴近用户问法,模型越容易命中。
3. 进阶场景:不改业务代码,给现有 REST 接口套一层 MCP
3.1 为什么需要“适配器模式”
很多团队的情况是:核心业务接口早就写好了,而且跑得很稳,不可能为了 AI 接入去改内部逻辑。这时候最理性的做法不是把 MCP 代码塞进老服务,而是新建一个独立的 MCP Server 进程,让它去调用老服务的 REST 接口。
这个思路我强烈推荐,因为它把“AI 接入层”和“核心业务层”彻底解耦了。核心服务不需要导入任何 MCP 相关包,不需要理解什么是工具、什么是 Tool Schema;MCP Server 只需要通过 HttpClient 去调用已有的接口。出问题的时候,排查范围也清晰:AI 调用不到数据,先查 MCP 进程日志,再查老服务访问日志,链路一目了然。
3.2 用 C# 写一个“转发型” MCP 工具
假设老服务里有个接口GET /api/products/{id},返回商品信息。我们的 MCP Server 里就可以这样写:
public class ProductTools { private readonly HttpClient _httpClient; public ProductTools(HttpClient httpClient) { _httpClient = httpClient; } [McpTool("get_product_detail", Description = "根据商品 ID 查询商品详情")] public async Task<string> GetProductDetail( [McpToolParameter(Description = "商品 ID,纯数字")] string id, CancellationToken cancellationToken) { var response = await _httpClient.GetAsync($"/api/products/{id}", cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); return body; } }启动注册的时候,给HttpClient配置 BaseAddress:
builder.Services.AddHttpClient<ProductTools>(client => { client.BaseAddress = new Uri(builder.Configuration["ProductServiceUrl"]!); });就这么简单,一个 MCP 工具就有了。模型拿到的信息和你老接口返回的 JSON 完全一致,不需要改任何业务代码。
3.3 处理老接口的鉴权和限流
老接口不是裸奔的,通常有 Token 鉴权或者 API Key。这种情况下,MCP Server 作为“中间人”,需要替模型请求携带凭证。我的做法是:在 MCP Server 配置里放一个服务账号的凭证,用DelegatingHandler统一注入 Authorization 头,MCP 工具方法里不用关心鉴权逻辑。
public class AuthHandler : DelegatingHandler { private readonly string _token; public AuthHandler(IConfiguration config) { _token = config["ProductServiceToken"]!; } protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { request.Headers.Authorization = new("Bearer", _token); return base.SendAsync(request, cancellationToken); } }注册的时候加上:
builder.Services.AddTransient<AuthHandler>(); builder.Services.AddHttpClient<ProductTools>() .AddHttpMessageHandler<AuthHandler>();限流方面,我的建议是给 MCP Server 调用外部服务时加上简单的“并发闸门”。模型有时会发起并发工具调用,如果老服务承受不住,一瞬间就会被压垮。用SemaphoreSlim控制最大并发数,超出的请求排队,这是一种低成本又有效的保护手段。
3.4 静态工具也能调用本地命令:把命令行变成 MCP
MCP 工具不一定是 HTTP 转发,它也可以直接调用本地能力。比如我做过一个例子:把 FFmpeg 的命令行操作封装成 MCP 工具,让 AI 直接转码视频。工具方法内部用Process.Start执行命令,读取 stdout/stderr 返回给模型。
这类“系统操作型”工具威力很大,但风险也大。我建议只暴露白名单内的命令,绝对不要做成让模型自由输入 shell 字符串的工具。那种“把参数拼进命令行”的写法,模型一旦生成恶意或者畸形参数,后果不可控。正确的姿势是:参数固定为枚举或者特定格式,代码里用列表白名单校验,任何不在白名单里的值直接拒绝。
4. 客户端落地:在 .NET 程序里调用 MCP 服务
4.1 最小客户端:连上服务并调用工具
服务端准备好了,现在写客户端。新建一个控制台项目,安装:
dotnet add package ModelContextProtocol客户端代码:
using ModelContextProtocol.Client; var client = await McpClientFactory.CreateAsync(new McpClientOptions { Transport = new McpTransportOptions { TransportType = TransportTypes.StreamableHttp, Url = "http://localhost:5150/mcp" } }); var tools = await client.ListToolsAsync(); foreach (var tool in tools) { Console.WriteLine($"{tool.Name}: {tool.Description}"); } var result = await client.CallToolAsync( "get_current_time", new Dictionary<string, object> { ["timeZoneId"] = "Asia/Shanghai" });CallToolAsync返回的是一个集合,每个元素对应工具的返回内容。如果是文本返回,取result.FirstOrDefault()转字符串即可。这个就是最朴素也最完整的 MCP 客户端调用链路:握手、发现工具、调用工具。
4.2 把 MCP 工具接入 AI Agent:Schema 的自动化
单独写一个客户端去调工具,只是第一步。真正有价值的场景是:把 MCP 的工具列表喂给你自己的 LLM Agent,让模型在对话过程中自动决定调用哪个工具。
大致的流程是:
- 通过
ListToolsAsync获取所有工具的 Schema(名称、描述、参数结构)。 - 把 Schema 传给 LLM 的 tools 参数。
- 模型返回一个 tool_call,里面包含工具名和参数 JSON。
- 程序根据工具名找到对应的 MCP 客户端实例,调用
CallToolAsync。 - 把调用结果作为 tool message 塞回给模型,模型生成最终回答。
这里需要特别留意的是,不同 LLM 供应商的 tools 参数格式有差异,但 MCP 的ListToolsAsync返回的工具 Schema 已经是 JSON Schema 风格,大部分供应商只需要做轻微的转换就能直接用。如果你用的 .NET LLM 库是Microsoft.Extensions.AI,还可以把 MCP 工具直接适配进去,这一步省了很多功夫。
4.3 客户端侧的错误处理与超时控制
MCP 工具是网络调用,一定会遇到超时、服务端异常、参数校验失败。客户端侧我习惯统一做一层 try-catch,把错误结果格式化成模型能理解的文本返回。比如:
try { var result = await client.CallToolAsync(toolName, args); return FormatResult(result); } catch (McpException ex) { return $"工具调用失败:{ex.Message}。请检查参数是否正确。"; } catch (OperationCanceledException) { return "工具调用超时,可能要稍后重试。"; }这里有个细节:模型看到错误信息后,通常会尝试修正参数重新调用。所以错误信息一定要写清楚“为什么失败”,比如参数不合法、订单不存在、网络超时。如果错误信息太模糊,模型会像个无头苍蝇一样反复试,浪费 token 还影响体验。
4.4 自己写一个极简 JSON-RPC 客户端(不依赖官方包)
如果因为环境限制用不了官方客户端包,你也可以直接用 HttpClient 自己发 JSON-RPC。MCP over HTTP 的底层就是 POST 一段 JSON 到端点,核心是initialize、tools/list、tools/call三个方法。
一个非常简化的流程是这样的:
var payload = JsonSerializer.Serialize(new { jsonrpc = "2.0", id = 1, method = "tools/call", @params = new { name = "get_current_time", arguments = new { timeZoneId = "Asia/Shanghai" } } });发过去以后,解析响应的result.content。这里面会有一个text字段,就是工具返回的文本内容。注意:用这种方式你需要手动维护 initialize 握手和 session id,官方包帮你把这些全包掉了。除非有特殊原因,我不建议在生产环境手搓,但理解这个底层流程有助于排查问题。工具调不通的时候,先拿 curl 试一下底层 JSON-RPC,能快速判断是协议问题还是 SDK 问题。
5. 常见问题与排查技巧实录
5.1 问题速查表
我把开发中遇到的高频问题整理成了表格,方便你排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 客户端连接时报 404 | MCP 端点路径不对 | 确认app.MapMcp()已调用,默认路径是/mcp |
| 工具调用超时 | 工具内部逻辑太慢或者外部服务无响应 | 用 CancellationToken 加超时,把耗时操作做成异步 |
| 模型反复调用同一个工具仍失败 | 工具返回的错误信息太模糊 | 优化错误信息,明确告知模型失败原因 |
| 模型生成的参数与工具 Schema 不匹配 | 参数描述不清或模型上下文被截断 | 精简参数数量,把参数描述写清楚 |
| ListTools 拿不到工具列表 | 工具类没有注册或者没有[McpTool]标注 | 检查AddMcpTool<T>和特性标注 |
| 服务端返回 CORS 错误 | 客户端跨域访问 MCP 端点 | 若为浏览器场景,配置 CORS |
| 返回的 JSON 被截断或换行异常 | 工具返回内容过大 | 对大结果做摘要或分页,控制单次返回体积 |
5.2 坑:Docker 容器里不能直接连宿主机的 MCP
我把 MCP Server 跑在 Docker 容器里,然后用宿主机上的客户端去连,结果一连就超时。后来发现问题很简单:容器里服务监听的地址如果是localhost,宿主机是访问不到的,需要监听0.0.0.0。反过来,容器内的客户端想连宿主机上的 MCP,也不能写localhost,要写宿主机 IP,在 Docker Desktop 场景下可以用host.docker.internal。
这个坑看起来很低级,但我在不同项目里被绊过两次。第一次是服务端没监听0.0.0.0,第二次是客户端把地址写死成localhost。Docker 网络和宿主机网络的隔离逻辑,在 MCP 这类“进程间/容器间通信”场景里特别容易踩。
5.3 坑:工具返回内容不要超长
大模型上下文窗口虽然有上限,但工具返回超长文本会带来几个问题:一是 token 费用飙升,二是模型在处理超长返回时容易忽略关键信息,三是可能触发供应商的上下文长度限制导致请求直接报错。
我的经验是,工具返回结果要尽量“瘦身”。列表类查询返回前 10 条就够了,明细类查询只返回核心字段。如果确实有多条数据,给模型一个“总数 + 摘要 + 分页参数”的结构,让模型决定要不要继续查下一页。还有一个细节:返回的文本不要带多余的空行、制表符和格式噪音,模型解析纯文本本来就吃力,别给它增加负担。
5.4 坑:使用 CancellationToken 和超时策略
模型调用工具的等待时间通常受供应商侧限制,比如 60 秒甚至更短。如果你的工具内部是一个耗时的数据计算,建议采用“提交任务 + 查询结果”的模式:第一次调用只返回任务 ID,让模型用另一个工具去轮询结果。这种异步模式虽然多了一个工具,但用户体验稳定很多。
另外,工具方法里一定要接住CancellationToken并传给内部的长耗时调用。.NET SDK 在客户端断开或超时时会取消这个 token,如果工具内部不响应取消,线程会一直挂着,并发一高服务就撑不住了。我见过一个教训:工具方法里调用第三方 HTTP 接口没传 token,结果客户端早就超时放弃了,服务端还在傻傻地等待响应,本来只需要 2 秒的请求最后耗了 30 秒才释放。
6. 一些亲测管用的工程化经验
6.1 服务的版本管理与多环境配置
MCP Server 和普通 Web 服务一样,会面临多环境部署问题。我建议把 MCP 端点路径、外部服务地址、凭证等全部放到配置里,不要写死在代码里。尤其要注意:开发环境往往连测试数据库,一旦 AI 工具上线到生产,连的却是生产库,后果很严重。所以在配置里显式区分ProductServiceUrl、DatabaseConnectionString这些键,并且部署时做环境变量覆盖,避免出现“测试环境模型调用生产订单接口”这种事。
6.2 权限意识:MCP 工具是权力的放大器
这一点我放在后面讲,是因为它太重要了。MCP 工具让大模型能直接操作业务系统,这本质上是在给 AI 授权。如果这个 AI Agent 是面向普通用户的,必须做用户隔离:不能出现“用户 A 让 AI 查到了用户 B 的订单”这种漏洞。工具方法内部要传用户上下文,而不是让模型自己猜用户身份。
我的建议是三层控制:第一层是网络层,MCP Server 只允许内网访问;第二层是身份层,客户端持有合法凭证,服务端校验后识别出调用者身份;第三层是业务层,工具方法内根据调用者身份做数据权限过滤,例如查询订单时强制加WHERE user_id = @currentUserId。千万别把 MCP 服务暴露到公网,还要对工具调用做审计日志,出问题时能追溯。
6.3 后续扩展:Resources、Prompts 与多工具编排
工具只解决了“执行动作”的问题,MCP 的另外两个能力也很值得玩。Resources 可以把你系统里的文档、配置、数据模板暴露给模型,让模型在回答前先“读一读”。Prompts 可以预置提示词模板,比如“总结一份工单”的模板,里面写好输出格式、注意事项,客户端拉取后直接填充内容就能用。
实际项目中,我见过一个挺经典的组合:用 Resource 暴露查询数据需要的表结构说明,用 Tool 暴露执行 SQL/查询订单的操作,再用 Prompt 把“你是数据分析助手”的角色定义好。三种能力配合起来,AI 不仅会“做事”,还知道自己该在什么场景下做什么事。
最后再总结一个我个人的实操体会:MCP 最大的价值其实不是技术本身,而是它把“AI 调用系统”这件事标准化了。以前你做 Function Calling,绑定的是某一个模型厂商;现在你做 MCP Server,绑定的是整个生态。.NET 这一侧的 SDK 虽然还在快速迭代中,但官方已经在很认真地推进,API 设计也和 ASP.NET Core 保持了一致。如果你的团队正在做 AI Agent 和内部系统的对接,我建议尽早把 MCP 纳入技术选型。先把一个最小闭环跑通,再逐步把更多工具加进来,这个投入产出比非常可观。