最近重构公司内部一个 AI 助手模块时我遇到一个非常典型的困境模型本身已经很聪明了真正卡住进度的是让它稳定、安全地调用企业内部那几十个老接口。试过在 Prompt 里塞 JSON 示例试过直接走 Function Calling 注册函数也试过自己写一套调用协议最后把方案稳定在了一个组合上用 MCP 统一工具暴露方式用 .NET 做能力层服务端用 Semantic Kernel 做模型编排。这篇文章就是这套系统方案的完整拆解适合已经跑通 SK 基础 Demo、正想往企业级工具集成方向走的人看。我会把架构逻辑、核心代码、生产环境要处理的鉴权并发可观测性以及真实项目里踩过的坑全部摆出来。先说结论MCP 不是银弹但它是目前把工具接入 AI这个重复劳动标准化的最好答案SK 也不是银弹但它的 Plugin 模型天然适合做 MCP 工具的编排层。两者叠 .NET 的强类型优势整个能力层会非常清晰。1. 从MCP 协议到能力层我们到底在解决什么问题1.1 AI 应用接入工具的三种历史姿势在我开始动手之前团队里其实已经用过三种方式给 AI 接工具。第一种是在 Prompt 里塞 JSON 示例告诉模型你想查订单就调用这个 URL参数长这样。这种方式前期最快但模型一旦遇到稍微复杂的输入组合就开始瞎编参数而且每个接口都要在 Prompt 里写一大段描述Prompt 很快膨胀到没法维护。第二种是直接用原生 Function Calling把函数注册到模型调用框架里。这个方式开发体验很好但问题是函数定义散落在代码里没有统一的注册、鉴权、审计机制每接一个新系统都要改一遍宿主代码。第三种是自己写一套 HTTP 调用协议规定好接口路径和参数格式。这种方式可控性高但等于给业务系统又加了一层私有规范后面任何新工具接入都要重新理解这套规范。这三种姿势的共同痛点在于工具接入的方式和模型无关却要模型适配各自的调用格式重复工作太多。我当时就想团队真正需要的不是一个调用工具的函数而是一个能力层——所有工具都以统一协议暴露模型侧、宿主侧、工具侧各自只维护自己的边界。1.2 MCP 把工具调用协议化之后发生了什么MCP全称 Model Context Protocol最早由 Anthropic 在 2024 年 11 月开源随后很快成了 AI 应用接入外部工具的事实标准。它做的事情和 USB-C 很像以前每个设备都有自己的充电接口现在统一成一种接口设备之间不再关心对方的私有格式。MCP 协议基于 JSON-RPC 2.0定义了三个角色Host模型应用进程比如自定义 AI Agent、IDE、Claude Desktop。ClientHost 内部与某个 Server 建立连接的组件一个 Host 可以挂多个 Client。Server把工具、资源、提示词暴露给模型侧的服务进程。协议核心的能力点有三个Tools工具调用、Resources资源读取、Prompts(提示词模板)。其中 Tools 是最常用的模型通过tools/list获取工具清单通过tools/call调用工具。我之所以强调能力层而不只是MCP 接入是因为当我们把企业内部所有系统都用 MCP Server 暴露之后这层就变成了 AI 与业务系统之间的标准化中间层。业务系统不需要知道模型是谁模型也不需要知道业务系统内部长什么样大家都只认 MCP 这个协议。1.3 为什么是 .NET Semantic Kernel而不是其他组合这个选择有技术原因也有现实原因。现实原因是我们公司的核心后端全是 .NET 技术栈运维、监控、安全体系都是围绕它建的我不会为 AI 工具集成单独引入一套异构运行时。技术原因则更关键.NET 的强类型对定义工具 Schema 很有优势。MCP Server 暴露一个工具时输入的 JSON Schema 是模型判断参数的唯一依据强类型可以让 Schema 的生成更可控避免运行时才暴露参数拼写错误。Microsoft.SemanticKernel 是微软官方 AI 编排框架C# 是一等公民和 .NET 生态融合度最高。SK 的 Plugin 模型天然就是用来描述模型可以调用什么能力的而 MCP Server 恰好可以提供这种能力的标准化描述。两者背后都有微软生态在推进协议版本迭代相对同步。SK 官方已经在迭代 MCP 连接器相关的预览包虽然我在生产环境没有直接用预览版但方向上很明确。如果你的团队是 Java 或者 Go 技术栈当然也能做同样的事情Python 端还有 LangChain 之类的方案。但既然标题是 .NET SK我下面的所有代码都会基于 .NET 8 和 Semantic Kernel 1.x 展开。2. 系统方案的总架构把能力层拆开看2.1 五个角色边界比代码更重要整套系统我认为可以拆成五个角色SK Agent Application最终用户面对的 AI 应用负责对话、意图识别、编排调用。Semantic Kernel模型编排层负责把用户的自然语言请求转成 Tool Call并自动调用已注册的 KernelFunction。MCP Client 桥接器运行在 SK Host 内部每个 MCP Server 对应一个 Client 实例负责把 MCP 工具翻译成 KernelFunction。MCP Server 能力层运行在业务系统旁边负责把具体业务能力封装成 MCP 工具。每个业务域一个 Server比如订单域、库存域、客户域。业务后端已有的微服务、数据库、第三方 SaaS APIMCP Server 负责调用它们。这个结构的关键在于MCP Server 不直接暴露给公网它只暴露给内部可信的 AI HostSK Agent Application 不直接依赖任何业务 SDK只通过 MCP Client 发现工具。这样接新系统的时候就只需要写一个新的 MCP Server宿主代码一行都不用改。2.2 传输层怎么选stdio 还是 Streamable HTTPMCP 传输层有两种主流方式stdio 和 Streamable HTTP。stdio 适合本地单机场景比如 AI 编辑器通过子进程启动一个 MCP Server进程间通过标准输入输出通信。它的优点是启动简单、没有网络开销缺点也很明显Server 生命周期跟着 Host 走无法独立部署也无法被多个应用共享。企业内部多个 AI 服务要共用同一个工具能力时stdio 模式根本不可行。所以我选 Streamable HTTP。它是 MCP 协议在 2025 年版本演进里主推的 HTTP 传输方式用 POST SSE 维持长连接具备会话能力。普通 HTTP 请求也能处理模型调用工具时就是一个 POST 请求返回结果通过响应体带回整个过程对基础设施非常友好。下面是两种方式的关键对比维度stdioStreamable HTTP部署形态随 Host 子进程启动独立服务进程可共享鉴权能力基本靠宿主隔离可加网关、Token、审计多应用共享不支持支持延迟最低多一次网络往返但可接受适用场景本地开发工具企业级能力层如果你只是个人电脑上写脚本stdio 很香一旦要上生产Streamable HTTP 几乎是唯一选项。2.3 不只是 ToolsResources 和 Prompts 也要规划MCP Server 里除了 Tools还有 Resources 和 Prompts 两种能力。Resources 是让模型读取结构化数据比如一个查询最新库存的 URIPrompts 是预置的提示词模板让模型按照特定格式输出。很多团队会忽略后面两个只管 Tools。我的建议是初期可以不做 Resources 和 Prompts但架构上要留出扩展位。我自己就遇到过一个场景业务方希望模型在调用下单工具前必须先读取一份合规检查清单这个用 Tools 链路实现起来很绕后来用 Prompts 模板解决得很干净。所以能力层的设计文档里我特意把三种能力都列上标注哪些已启用、哪些规划中。3. 动手实现用 .NET 8 搭建第一个 MCP Server3.1 依赖与项目结构我用的 NuGet 包是ModelContextProtocol.AspNetCore它把 MCP Server 的协议处理、工具发现、HTTP 映射都封装好了。还用到ModelContextProtocol作为基础类型库。项目结构上我建议单独拉一个类库放工具定义不要让 Program.cs 变成一堆业务代码的垃圾场。一个典型的项目结构是这样Mcp.OrderServer/ ├── Program.cs # 入口注册服务 ├── Tools/ │ ├── OrderTools.cs # 订单查询工具 │ ├── OrderPrecheckTools.cs # 订单预占工具 └── Services/ ├── IOrderService.cs └── OrderService.cs # 内部调老系统的 HTTP 客户端3.2 把已有业务方法暴露成 MCP 工具的完整代码下面是一个真实可跑的示例。程序集自动扫描带上McpServerToolType特性的类类里的方法通过McpServerTool特性暴露成 MCP 工具。// Program.cs var builder WebApplication.CreateBuilder(args); builder.Services.AddMcpServer() .WithToolsFromAssembly(typeof(OrderTools).Assembly); builder.Services.AddSingletonIOrderService, OrderService(); var app builder.Build(); app.MapMcpServer(/mcp); app.Run();// Tools/OrderTools.cs using ModelContextProtocol.Server; [McpServerToolType] public sealed class OrderTools { private readonly IOrderService _orderService; public OrderTools(IOrderService orderService) { _orderService orderService; } [McpServerTool(Name query_order)] [Description(根据订单号查询订单状态、金额和物流进度。)] public async Taskstring QueryOrder( [Description(订单号格式如 SO2025001必填。)] string orderNo, CancellationToken ct) { var order await _orderService.GetByNoAsync(orderNo, ct); if (order is null) { return {\found\: false, \error\: \订单不存在\}; } return System.Text.Json.JsonSerializer.Serialize(new { found true, orderNo order.No, status order.Status, amount order.Amount, logistics order.Logistics }); } }注意两个细节。第一工具的返回类型我统一用Taskstring返回内容永远是 JSON 字符串。原因是 MCP 协议里 Content 是通用的你传对象也可以但CallToolResponse的序列化规则容易出现嵌套结构给模型解析带来干扰直接返回字符串最稳。第二Description特性写清楚参数格式比如SO2025001这样模型才知道参数到底长什么样。3.3 工具描述怎么写模型才不会乱调用这是整个能力层最容易被低估的地方。我见过太多人把工具方法写好Description 随便写一句查询订单然后模型在用户问我的包裹到哪了时不调用它反而自己去编答案。工具描述的核心原则是描述要包含触发条件和参数格式两层信息。一个合格的描述例子是这样当用户询问订单状态、物流进度或金额时使用此工具。订单号格式为 SO 加 6 位数字。如果用户没有提供订单号请先向用户询问。参数描述同样重要。比如orderNo参数我写了格式如 SO2025001模型就知道不能传普通数字。inputSchema会自动从这些特性生成强类型的价值就在这里string orderNo对应 string 类型加上Description后 Schema 就带上了参数说明这个 Schema 才是模型实际看到的东西。4. 在 Semantic Kernel 里接入 MCPClient 适配与函数桥接4.1 MCP Client 的初始化与传输配置SK Host 侧需要一个 MCP Client 来连接 Server。这里我用的是手动桥接方案因为生产环境需要自定义超时、错误映射和审计字段预览包还达不到我要的控制粒度。// host 启动时为每个 MCP Server 创建一个 Client var loggerFactory LoggerFactory.Create(builder builder.AddConsole()); var factory new McpClientFactory(); await using var client await factory.CreateAsync( new McpClientOptions { Transport new HttpClientTransport( new Uri(http://order-mcp-server.internal:8080/mcp) ) }, loggerFactory: loggerFactory, cancellationToken: cancellationToken); // 获取工具清单 var mcpTools await client.ListToolsAsync(cancellationToken);一个 Host 可以同时持有很多个 Client每个 Client 连接一个 MCP Server。创建好 Client 之后我需要把mcpTools里的每一个工具变成 SK 能识别的KernelFunction。4.2 把 Remote Tools 包装成 KernelFunction 的两种方式第一种是直接用 SK 官方提供的连接器预览包它会自动做映射。第二种是手动包装这也是我生产环境用的方式。手动包装代码大概长这样public static KernelFunction ConvertToKernelFunction( McpClient client, McpTool tool) { return KernelFunctionFactory.CreateFromMethod( async (string? input, CancellationToken ct) { var arguments string.IsNullOrWhiteSpace(input) ? new Dictionarystring, object?() : JsonSerializer.DeserializeDictionarystring, object?(input) ?? new Dictionarystring, object?(); var response await client.CallToolAsync(tool.Name, arguments, ct); var text response.Content .OfTypeTextContent() .Select(c c.Text) .FirstOrDefault(); return text ?? tool call completed; }, new KernelFunctionMetadata(tool.Name) { Description tool.Description, Parameters BuildParametersFromSchema(tool.InputSchema) }); } public static KernelPlugin CreatePluginForServer( McpClient client, string pluginName, IEnumerableMcpTool tools) { var functions tools.Select(t ConvertToKernelFunction(client, t)); return KernelPluginFactory.CreateFromFunctions(pluginName, functions); }这里有个容易踩的坑模型调用工具时传的参数结构是由KernelFunctionMetadata决定的而实际调用 MCP Server 时参数又被反序列化成Dictionarystring, object?发出去。所以BuildParametersFromSchema一定要从tool.InputSchema精确解析不能自己硬编码参数名。我最初就是偷懒没解析 Schema模型传的参数和 MCP Server 期望的字段名不一致排查了半天。4.3 事件链路一个 Tool Call 到底走了几步当 SK 自动调用一个 MCP 工具时链路如下模型根据对话内容决定调用query_order函数。SK 的ToolCallBehavior.AutoInvokeKernelFunctions找到对应 KernelFunction。KernelFunction 的InvokeAsync触发内部调用我们包装的CreateFromMethod。方法通过 MCP Client 发出tools/call请求走 HTTP 到 MCP Server。Server 反序列化参数调用业务方法返回 JSON 字符串。JSON 字符串作为工具返回内容回传给模型模型据此生成最终答复。每一步之间都是标准的协议消息意味着每一步都可以插桩、审计、限流。这和以前直接在 SK 里注册本地函数相比多了一个网络往返但换来了统一治理能力。5. 生产环境绕不开的四个问题鉴权、并发、超时、可观测性5.1 鉴权与令牌方案MCP 协议本身不规定鉴权方式所以能力层必须自己搞定。我的方案是在 MCP Server 前面挂一个鉴权中间件校验 Bearer TokenSK 侧的每个 MCP Client 通过请求头携带 Token。// Server 侧中间件 app.Use(async (context, next) { var token context.Request.Headers.Authorization.ToString(); if (string.IsNullOrEmpty(token) || !IsValidToken(token)) { context.Response.StatusCode StatusCodes.Status401Unauthorized; return; } await next(); }); // SK 侧 Client 配置 new McpClientOptions { Transport new HttpClientTransport(new Uri(http://order-mcp-server.internal:8080/mcp)) }; // 在 HttpClientTransport 创建时传入默认请求头 var httpClient new HttpClient(); httpClient.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, serverConfig.Token); var transport new HttpClientTransport(httpClient, new Uri(http://order-mcp-server.internal:8080/mcp));Token 的粒度建议按 MCP Server 分配不要所有 Server 共用一个 Token。这样某个 Server 出了问题可以单独吊销不会影响全局。另外所有 Token 必须走配置中心或密钥管理服务不要硬编码在配置文件里。5.2 并发控制模型可能瞬间打出十个请求很多人第一次看 SK 自动调用工具时会很震惊模型为了解决一个问题可能一次性发出多个并行的 Tool Call。如果你的 MCP Server 背后是数据库或者老系统接口这种并发直接就把服务打挂了。我在 Server 侧给每个工具加了 SemaphoreSlim 控制并发量超过阈值的请求直接排队或返回 429。public sealed class ThrottledOrderTools { private static readonly SemaphoreSlim _gate new(4); [McpServerTool(Name query_order)] [Description(根据订单号查询订单状态、金额和物流进度。)] public async Taskstring QueryOrder( [Description(订单号格式如 SO2025001必填。)] string orderNo, CancellationToken ct) { await _gate.WaitAsync(ct); try { // 实际业务逻辑 } finally { _gate.Release(); } } }限流阈值怎么定我根据后端数据库的连接池大小来的连接池 10工具并发就压到 4给其他业务留余量。这一条是用线上事故换来的教训值得写在方案里。5.3 超时与失败重试策略MCP Server 调用后端老系统老系统可能会慢而模型侧等待工具返回的耐心是有限度的。我统一在 MCP Client 调用时加了CancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(30))。using var cts new CancellationTokenSource(TimeSpan.FromSeconds(30)); var response await client.CallToolAsync(tool.Name, arguments, cts.Token);重试只对幂等查询类工具开启比如query_order可以重试一次写操作类工具比如下单、扣减库存坚决不自动重试而是返回错误让模型向用户确认。这个原则要写进能力层的接入规范里否则一旦模型自动重试了写操作后果很严重。5.4 可观测性能力清单和日志链路模型调用工具的次数会快速增长没有可观测性就等于蒙眼开车。我做了两件事。第一能力清单导出。启动时扫描所有 MCP Server 的tools/list把工具名、Description、参数 Schema、所属 Server 汇总成一份 JSON 文档定期推送到内部文档站。这样业务方可以看到 AI 现在能做什么不会反复过来问能不能帮我查个库存。第二链路日志。在 MCP Server 侧输出结构化日志包含serverName、toolName、requestId、durationMs、httpStatus这几个字段。配合 OpenTelemetry把工具的调用时间也打进 Trace Span。这样定位问题能直接问这个工具平均耗时多少、失败率多少而不是靠猜。6. 用一个端到端例子把方案串起来订单查询 库存预占6.1 场景与假设假设业务背景是一个电商运营后台ERP 系统里有订单域和库存域接口都是老 SOAP 风格。AI 助手的任务是让运营人员用自然语言查订单并检查某个订单涉及的商品库存是否足够预占。我给库存域也写一个 MCP Server暴露一个precheck_stock工具。整个链路是SK Host 同时挂两个 MCP Client一个是订单 Server一个是库存 Server。用户问查一下订单 SO2025001 的库存预占情况SK 会先调query_order拿到商品 SKU再调precheck_stock拿到库存结果。6.2 库存 MCP Server 的补充代码库存工具的代码和订单工具几乎一样只是业务方法不同[McpServerToolType] public sealed class InventoryTools { private readonly IInventoryService _inventoryService; public InventoryTools(IInventoryService inventoryService) { _inventoryService inventoryService; } [McpServerTool(Name precheck_stock)] [Description(检查指定 SKU 在指定仓库是否有足够库存用于订单预占。)] public async Taskstring PrecheckStock( [Description(商品 SKU 编码)] string sku, [Description(仓库编码如 WH-01)] string warehouseCode, [Description(需要预占的数量)] int quantity, CancellationToken ct) { var result await _inventoryService.PrecheckAsync(sku, warehouseCode, quantity, ct); return System.Text.Json.JsonSerializer.Serialize(result); } }6.3 SK Host 侧的调度代码var kernel Kernel.CreateBuilder() .AddOpenAIChatCompletion(gpt-4o-mini, apiKey) .Build(); await using var orderClient await factory.CreateAsync(...); await using var inventoryClient await factory.CreateAsync(...); var orderPlugin CreatePluginForServer(orderClient, order, orderTools); var inventoryPlugin CreatePluginForServer(inventoryClient, inventory, inventoryTools); kernel.Plugins.Add(orderPlugin); kernel.Plugins.Add(inventoryPlugin); var result await kernel.InvokePromptAsync( 查一下订单 SO2025001 涉及的库存是否足够预占, new KernelArguments(new OpenAIPromptExecutionSettings { ToolCallBehavior ToolCallBehavior.AutoInvokeKernelFunctions })); Console.WriteLine(result);这段代码跑通后你会发现 SK 自己会拆解任务先调query_order拿到订单里的 SKU 列表再调precheck_stock。你不用在代码里写任何编排逻辑模型根据工具描述自动完成了。这正是能力层的价值——能力是独立的编排交给模型。6.4 实测遇到的四个坑第一个坑输入参数为 null。模型如果没有抓到订单号就会传一个空对象进来JsonSerializer.DeserializeDictionarystring, object?(input)直接抛异常。后来我在包装函数里做了空值兜底并返回一条提示消息告诉模型缺少必要参数请向用户询问订单号。第二个坑空参数列表。有些工具一个参数都没有模型还是会调用此时arguments可能是空字典。MCP Client 的CallToolAsync对空字典处理方式不稳定我统一传new Dictionarystring, object?()避免序列化出错。第三个坑长文本响应。模型如果一次性要查很多订单工具返回内容很大单个 HTTP 响应体没问题但模型上下文窗口可能被占满。我给查询类工具加了返回条数上限默认最多返回 20 条并明确在 Description 里告知模型超过数量要分批查询。第四个坑工具返回的 JSON 里包含双引号转义。有一次我在返回字符串里直接拼了订单备注备注里有换行和引号结果 JSON 解析错乱。后来所有返回内容都统一用JsonSerializer.Serialize做一层包裹确保内容始终是合法 JSON。7. 做取舍时的一些真实感受7.1 什么时候不要上 MCPMCP 再好也不该无脑用。如果你的 AI 应用只需要调用本地三五个函数函数之间强依赖、低延迟、无网络边界直接在 SK 里把方法注册成 KernelFunction 就好了上 MCP 等于给每个调用多一次序列化和网络开销。另外如果业务系统完全不允许外部网络访问SSH 都开不了那也别折腾 HTTP 传输stdio 模式反而更合适。MCP 的价值在多系统、多团队、可共享的企业级场景不在单体小工具。7.2 从零搭建的最小实现顺序如果你打算在团队里落地这套方案我的建议是按这个顺序推进先拿一个无状态查询接口做试点 MCP Server暴露一个工具再在 SK Host 里接一个 Client跑通自动调用然后补上鉴权和链路日志最后再把第二个 Server 接进来。不要一上来就设计十个工具、三个 Server那样出问题你根本不知道是协议问题、Schema 问题还是 SK 编排问题。我最后想说的是构建 MCP 能力层这件事技术本身并不难难的是把工具描述写清楚、把并发和鉴权想明白、把可观测性做起来。这些细节决定了模型到底是一个能用的助手还是一个好用的助手。如果你现在也在用 .NET SK 做 AI 应用集成建议从小工具开始先把协议链路走通再把能力边界逐步扩大。等你的 MCP 能力层稳定跑起来之后你会发现新增一个工具接入从一周缩短到半天这个收益才是最值得投入的地方。