正式发布 MCP C# SDK 2.0 版本
模型上下文协议(Model Context Protocol,MCP)C# SDK 现已发布 v2.0 版本,实现了 MCP 规范 2026-07-28 修订版。这是该协议自推出以来最大的一次修订。与之前的版本不同——之前的更新是在现有协议框架上增加能力,而 2026-07-28 修订版则回到基础,重新思考 MCP 在 HTTP 上的工作方式。它让协议默认变成无状态,标准化了 HTTP 接口,使普通的 HTTP 基础设施就能路由 MCP 流量,并引入了多轮往返请求(Multi Round-Trip Requests),这样一来交互式工具不再需要长期保持会话。
这个转变正好发挥了 .NET 的优势。毕竟 MCP over HTTP 本质上就是 Web 工作负载,而 ASP.NET Core 一直以来专注的正是这个 MCP 规范修订版所关心的东西:路由、中间件、标头、负载均衡和水平扩展。MCP C# SDK 直接构建在 ASP.NET Core 之上,所以新规范要求的很多能力在 .NET 中已经是家常便饭。
在深入介绍之前,先给各位吃一颗定心丸:v2.0 是向后兼容的。升级 SDK 并不会强制你放弃已有的客户端和服务器,你稳定的 v1 代码依然能编译和运行。后面我们会详细展开这个承诺,但先记住这一点,让我们来看看新功能。
所有修订内容一览
2026-07-28 修订版是一组协调的规范增强提案(Specification Enhancement Proposals,SEPs)。所有变更的完整列表请参阅规范变更日志。关于设计背后的思路,MCP 维护者的 2026-07-28 公告是很好的延伸阅读材料。下面带你快速浏览新特性。
默认无状态
在之前的规范中,通过 Streamable HTTP 调用工具需要先完成初始化握手,并且按照 v1 SDK 默认设置,还需要建立一个会话。服务器会返回一个 Mcp-Session-Id,客户端必须在后续每个请求中带上它,将请求绑定到发出该 ID 的那台服务器实例上。水平部署时,要么得用粘性路由,要么就得做会话迁移,才能正常工作。
官方 MCP C# SDK v2.0 发布说明
协议在 2026-07-28 修订版中做了一项关键调整:连接级别的配置被替换为自包含请求。具体来说:
- 移除了
initialize/initialized握手流程(参见 SEP-2575) - 移除了
Mcp-Session-Id请求头(参见 SEP-2567) - 协议版本和功能信息现在随每个请求传递
实际效果是:任何服务器实例都可以处理任何请求,因此横向部署所需的粘性会话和共享会话存储在协议层不再必要。无服务器、多实例和边缘部署可以直接运行,无需额外配置。
SDK 紧随规范同步更新:HTTP 服务器传输现在默认以无状态方式运行。在 v1 中默认配置的是有状态会话,而 v2 中将 HttpServerTransportOptions.Stateless 默认值设为 true。
using ModelContextProtocol.Server;
v2.0 正式发布:官方 MCP C# SDK
下面这段代码展示了新版 SDK 的简洁用法,核心变化是默认采用无状态 HTTP 传输:
using System.ComponentModel;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport() // 现在默认无状态
.WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp();
app.Run("http://localhost:3001");
[McpServerToolType]
public static class EchoTool
{
[McpServerTool, Description("Echoes the message back to the client.")]
public static string Echo(string message) => $"hello {message}";
这段代码用最少的配置搭建了一个 MCP 服务器,WithHttpTransport() 默认采用无状态模式(stateless),不再依赖长连接,更适合云原生场景。EchoTool 类上的 [McpServerToolType] 和 [McpServerTool] 属性让工具注册变得一目了然 —— 开发者只需在方法上加上描述,SDK 即自动把它暴露为 MCP 工具。
这就是整个服务器。把它放在轮询负载均衡器后面,想开多少个实例都行,实例之间完全不需要同步。容器化也很干净:一个无状态的 MCP 服务器就是普通的 ASP.NET Core 应用,用 .NET 常见的多阶段 Dockerfile 就能打包,扔到任何支持容器的环境里运行。“无状态协议”不等于“无状态应用”。如果你的服务器需要在多次调用之间维护状态,那就和 HTTP API 一直以来的做法一样:从某个工具里生成一个显式的句柄(比如 basketId、browserId),然后让模型在后续调用中把它当作普通参数传回来。事实证明,让模型在调用之间传递一个标识符,往往比把会话状态藏在传输层元数据里更强大——模型可以跨工具组合句柄、对其进行推理,还能在步骤之间进行交接。
自由选择会话模式
无状态是默认行为,但不是强制要求。如果你确实需要服务器主动向客户端推送消息,或者需要基于会话的传输层状态,仍然可以切换到有状态模式。由于会话现在是主动选择的,旧的 SSE 端点以及少数只支持有状态的选项默认已被关闭或废弃(诊断提示 MCP9004 和 MCP9006),如果你依赖旧行为,系统会友好地提醒你。原则是“按需付费”:只有真正用到会话功能时,才承担相应的复杂度。
还有一个更微妙的理由把无状态设为默认值,这不仅仅是出于扩展性,更是为未来考虑。因为 2026-07-28 版本的线缆格式直接去掉了初始化握手和 Mcp-Session-Id,所以启用 Stateless = true 是向前兼容的选择:这种配置能让你的服务器直接原生理解新协议,直接响应 2026-07-28 版本的客户端。旧版本客户端也不会被抛弃(服务器仍会为它们回退到旧握手流程),但新客户端可以直接走现代路径,毫无阻碍。
基于普通 HTTP 构建,天生可扩展
无状态改变了 MCP 请求的形态:现在它只是一个自描述的 HTTP POST 请求。这打开了一扇之前无法通行的大门:你现有的 HTTP 基础设施终于可以像处理其他流量一样处理 MCP 了。
无需边车、无需解析请求体、无需特殊处理。2026-07-28 修订版标准化了一小组 HTTP 头,它们正好对应中间件真正关心的字段(参照 SEP-2243)。现在,tools/call 请求携带 Mcp-Method: tools/call 和 Mcp-Name: get_order_status 的同时,也会带上 JSON-RPC 请求体。如此一来,负载均衡器、代理、网关、WAF 或可观测性工具就能直接作用于 MCP 流量,而无需深度包检测。你只需添加一个属性,就能将任意工具参数提升为 Mcp-Param-* 头。这正是地理分布式路由所需要的场景。想象一个工具调用后端服务(比如订单服务),该服务部署在多个区域。工具接收 region 和 orderId 两个参数,全局负载均衡器需要将每次调用发送到同区域的部署实例。将 region 提升为头字段后,路由器可以直接根据它进行分发,完全无需读取请求体:
[McpServerTool(Name = "get_order_status"),
[Description("从区域订单服务获取订单状态")]
public static async Task<string> GetOrderStatus(
OrdersServiceClient orders,
[McpHeader("Region"), Description("订单服务区域")] string region,
[Description("要查询的订单 ID")] string orderId)
{
// 客户端会自动将 region 的值注入到请求头中:
// Mcp-Param-Region: eastus2
return await orders.GetStatusAsync(region, orderId);
[McpHeader] 属性可以标注参数,并在工具的输入 schema 中插入一个 x-mcp-header 关键字。客户端看到这个关键字后,就会知道应该把该参数在传输时提升为 HTTP 头部。
标准化头部的设计目标,对于任何在代理后面跑过服务的人来说,读起来就像一封情书。它们的设计是为了:
- Mirror(镜像):把方法名、名称和选中的参数镜像到每次请求的头部。
- Inspect nothing(不检查请求体):中间件仅凭头部做路由,完全不检查请求体内容。
- Keep authoritative(保持权威):JSON-RPC 的 body 是权威来源,服务器拒绝任何与 body 不一致的头部。
- Encode non-ASCII(安全编码非 ASCII 值):用 Base64 哨兵安全编码非 ASCII 值,保证头部干净。
第三个目标对于正确性至关重要。body 始终是事实来源;如果头部和 body 不一致,服务器不会猜测,而是直接拒绝该请求,返回 HeaderMismatch 错误。
整个特性是附加的、不破坏现有功能的:客户端在 Streamable HTTP 上发送这些头部,但服务器只在双方都协商到 2026-07-28 版本之后才强制执行,所以你现在的东西不会坏。
这种特性只有事后看来才显得理所当然。它非常适合 ASP.NET Core,因为头部、路由和中间件正是 ASP.NET Core 的原生表达方式。如果允许我感性一点,这也是我个人最喜欢的功能之一:提出它的人,也正是第一个 .NET 实现的作者——当提案和原型出自同一键盘时,效果就是不一样。
请求用户输入
到目前为止,无状态的故事全是优点。但有一个波折需要直说,因为这就是 v2 要解决的核心问题:有些工具无法在一次往返中完成回答。
- 一个重要的操作,想先让用户确认。
- 一个摘要工具,想让客户端的 LLM 先草拟一些内容。
- 一个文件工具,想知道自己可以访问哪些工作区根目录。
在旧世界里,这三种场景(引导、采样和根目录)都是服务器发起的请求:服务器在调用过程中回头向客户端询问,然后等待回答。这种模式只能在一个活跃的、有状态的会话中工作。它需要从服务器到客户端的持久通道,而这正是无状态模型所放弃的。
MCP C# 官方 SDK v2.0 正式发布
MCP 之所以能优雅地扩展,正是因为每个请求都可以落到任意实例上——但这也正是交互式工具难以实现的原因。如果故事到此为止,“无状态”就得打个星号:扩展性很好,但失去了交互性。好在故事并没有结束。
多轮往返请求(Multi Round-Trip Requests)
多轮往返请求(MRTR)就是解决方案,这也是 2026-07-28 修订版(SEP-2322)的重磅特性。它不再让服务器在会话中主动回连客户端,而是返回一个结果,其含义是:“我需要你先提供一些东西。”具体来说,工具会返回一个 InputRequiredResult(resultType 为 "input_required"),其中包含一个或多个输入请求以及一个不透明的 requestState 数据块。客户端拿到这些输入请求后(比如提示用户、调用自身的 LLM、列出根目录),将收集到的 inputResponses 和原封不动的 requestState 重新发起同一个 tools/call 调用。这个过程可以重复多轮,而且关键的一点是——它完全不依赖会话,因为所有连续性信息都随请求载荷一起传递。
服务端对 MRTR 的支持
在服务端,你可以在工具实现中抛出 InputRequiredException,并传入你需要的输入以及一个 requestState 字符串(该字符串会在重试时原样返回给你)。你可以使用工厂方法构建单个请求:
InputRequest.ForElicitation(...)—— 用于引导用户输入InputRequest.ForSampling(...)—— 用于采样/调用 LLMInputRequest.ForRootsList(...)—— 用于列出根目录
下面是一个工具示例:在执行重要操作前先请求确认。
[McpServerTool, Description("Closes a support ticket, recording why it was closed.")]
宣布官方MCP C# SDK v2.0
public static string CloseSupportTicket(
McpServer server,
RequestContext<CallToolRequestParams> context,
[Description("The ID of the ticket to close")] long ticketId,
[Description("Why the ticket is being closed")] string? closeReason = null)
{
// Handles four client scenarios:
// 1. Provided up-front: client sends `closeReason` in the initial call
// 2. MRTR round-trip request: client confirms via `InputResponses["closeReason"]`
// 3. MRTR initial request: server proposes a default reason and asks for confirmation
// (with automatic SDK down-level bridge)
// 4. Session-less down-level: server returns a guidance message requesting the reason up-front
// The default reason proposed to the caller and used if none is provided.
string defaultCloseReason = "completed";
// (1) Provided up-front. Works on any client, including down-level session-less.
// These requests are typically sent after (4) returns a guidance message.
var confirmedReason = closeReason;
// (2) MRTR round-trip request. Works with native MRTR support or the automatic down-level
// SDK bridge after (3) throws an `InputRequiredException` to request a `closeReason`.
if (string.IsNullOrWhiteSpace(confirmedReason) &&
context.Params?.InputResponses?.TryGetValue("closeReason", out var reasonResponse) is true)
{
var reasonResult = reasonResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
// Branch on the elicitation action: `decline` or `cancel` leaves the ticket open.
if (reasonResult?.IsAccepted is not true) return "Ticket close cancelled";
// Accepted: use the reason the caller confirmed, falling back to the proposed default.
confirmedReason = reasonResult.Content?.TryGetValue("closeReason", out var reasonValue) is true
? reasonValue.GetString()
: null;
confirmedReason = string.IsNullOrWhiteSpace(confirmedReason) ? defaultCloseReason : confirmedReason;
}
// (1) or (2) A reason is in hand; proceed with closing the ticket.
if (!string.IsNullOrWhiteSpace(confirmedReason))
return $"Closed ticket {ticketId}: {confirmedReason}";
// (3) MRTR initial request: propose "completed" as the default reason and ask the caller
// 确认(或调整)它。这里使用了 2026-07-28 的 MRTR 输入请求,但 SDK
// 会提供自动桥接到旧版状态会话的 elicitation 流程。当桥接可用时,`server.IsMrtrSupported` 为 `true`,
// 异常会自动触发旧版 elicitation 响应。
if (server.IsMrtrSupported)
{
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["closeReason"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"关闭工单 '{ticketId}'?接受默认原因或自行提供。",
RequestedSchema = new()
{
Properties =
{
["closeReason"] = new ElicitRequestParams.StringSchema
{
Title = "关闭原因",
Description = "关闭工单的原因",
Default = defaultCloseReason,
},
},
},
})
},
requestState: ticketId.ToString()); // 不透明值;重试时会回传给调用方
}
// (4) 降级且无状态:无法通过 MRTR 往返请求或 elicitation 提示输入。返回自然语言响应,
// 并提示用户提前提供原因。
return "关闭工单需要提供原因。请重新发送,并附带 `closeReason`。";
标题:官方 MCP C# SDK v2.0 发布
一种方法,适用于所有客户端。该工具在两种情况下可以处理一次往返通信:一种是原生支持 MRTR 的客户端(如 2026-07-28 客户端),另一种是在有状态会话中的低版本客户端,此时 SDK 会将相同的 throw 请求桥接到旧式的 elicitation 机制中。在这两种情况下,它都会提供一个默认的关闭原因,并在从 context.Params.InputResponses 重试时完成操作。唯一无法处理的情况是无会话的低版本客户端——也就是 McpServer.IsMrtrSupported 排除掉的那种。在这种情况下,它会回退到 closeReason 参数,由调用方直接提供原因,而不会让调用卡住。同一个工具,无论客户端是否支持 MRTR,都可以正常工作。下面的兼容性表格列出了全部四种情况。
客户端对 MRTR 的支持
在客户端侧,高层级的 McpClient 会自动处理 MRTR。注册对应的处理程序后,客户端会满足输入请求并为你重新发起调用。你只需要从 CallToolAsync 拿到最终结果即可。
var client = await McpClient.CreateAsync(
clientTransport,
clientOptions: new()
{
Handlers = new McpClientHandlers
{
ElicitationHandler = (requestParams, ct) =>
{
// 接受提议的默认关闭原因。
return ValueTask.FromResult(new ElicitResult { Action = "accept" });
},
}
});
// 客户端透明地处理 input_required 的往返通信。
var result = await client.CallToolAsync(
"close_support_ticket",
new Dictionary<string, object?> { ["ticketId"] = 1234L });
MRTR 模式详解
cancellationToken: CancellationToken.None);
一条模式,替代三种旧写法。因为 MRTR 将「服务端需要从客户端获取东西」的交互方式通用化了,所以在无状态服务器上,它用统一的机制替代了之前服务端主动发起请求的 elicitation、sampling 和 roots 模式。
- Elicitation(提示获取):现在通过
InputRequiredException和InputRequest.ForElicitation(...)实现。旧版的ElicitAsync方法在有状态会话中仍然可用,但在无状态模式下会直接抛出异常,因为没有 session 来承载服务端发起的请求了。 - 安全的带外授权(第三方 OAuth、敏感数据):v2 新增了
UrlElicitationRequiredException,用于无状态流程中的 URL 模式提示获取。具体流程是:客户端展示一个服务端托管的 URL,在带外完成用户授权,然后重试。更多细节请参考「URL 模式提示获取(带外)」文档。 - Sampling 和 Roots:根据 SEP-2577 规范,已被标记为弃用(诊断代码 MCP9005)。
SampleAsync和RequestRootsAsync方法仍然可用,但在无状态模式下同样会抛出异常。 - 日志(Logging) 也被弃用(同样属于 SEP-2577),因为其功能已与 stderr 和 OpenTelemetry 重叠。引用这些 API 时会触发 MCP9005 警告。
MRTR 向后兼容性
MRTR 设计上可以优雅降级。以下是它在不同协议版本和会话模式下的表现:
| 协商的协议版本 | 会话模式 | MRTR 行为 |
|---|---|---|
| 2026-07-28 | 无状态 | 原生:无需服务端维护 handler 状态 |
| 2026-07-28 | 有状态 | 原生:InputRequiredResult 直接序列化到网络传输 |
| 2025-11-25 及更早 | 有状态 | 向后兼容:SDK 会自动桥接回老的基于 session 的请求模式 |
| 2025-11-25 及更早 | 无状态 | 不支持:输入请求会以 McpException 异常形式抛出 |
上面表格的第三行解释了为什么前面 CloseSupportTicket 工具示例不需要写低版本兼容代码:当 v2 服务端通过有状态会话与旧版客户端通信时,SDK 会自动将 MRTR 桥接回旧的服务端主动请求模式。
第四行是唯一完全无法触发提示的情况(无会话的旧版本客户端),这正是该工具还接受可选参数 closeReason 的原因——它为调用方提供了一条非交互式的出路,而非死胡同。
设计上保持向后兼容
接下来兑现文章开头的承诺。主版本号升级常常让人不安,所以我们具体说明一下这里的“向后兼容”到底指什么:
- 你的 v1 代码照常工作。
- 稳定、未被弃用的 1.x API 在 2.0 中仍然可以编译和运行。
- 本次发布引入的弃用项(服务器发起的请求对应 MCP9005、仅状态选项对应 MCP9006、传统 SSE 对应 MCP9004)只是警告,而非移除。你可以按自己的节奏迁移。
旧客户端和服务器在双向通信中仍然正常工作。v2 客户端与旧版服务器通信时,会透明地使用传统的初始化握手;v2 服务器也仍然接受来自旧版客户端的握手。升级 SDK 不会让你在连接的任何一端陷入困境。
这是 SDK 有意识地采取的姿态:其主版本号跟随规范修订,但保留对旧版协议的支持,因此版本升级不会强制要求“同步更新日”。我们在发布 v1.0 时声明的版本策略,在 v2.0 中保持不变。
我们在公开过程中一直在验证这一承诺。在 v2 预览版发布期间,采纳者反馈了我们最想听到的消息:无论是客户端还是服务器,都保持与之前规范修订版本的向后兼容。v2 客户端可以驱动旧版服务器,v2 服务器也可以服务旧版客户端,无需你进行特殊处理。
唯一诚实的例外:Tasks
v2 与 v1 在协议层面不兼容的地方只有一处:Tasks 扩展。2.0 中重新设计的 Tasks(SEP-2663)取代了 2025-11-25 规范扩展中的实验性 Tasks(在 v1.3.x 和 v1.4.x 中受支持),并且在 API 和协议层面均不兼容。v2 客户端调用 v1 任务服务器只会得到普通的工具结果,反之亦然。
如果你采用了实验性的 Tasks 预览版,这是唯一需要规划迁移的地方。好消息是,新设计要好得多。更多细节见下文。
迁移要点如下:
-
HTTP 传输默认改为无状态
v1 中 HTTP 传输默认有状态;v2 改为默认无状态。接受新的无状态默认值,或在需要会话时显式设置Stateless = false。 -
实验性 Tasks 已内置到 Core 中
通过McpServerOptions.TaskStore和McpClientOptions.TaskStore配置。需添加ModelContextProtocol.Extensions.Tasks包;在服务端使用.WithTasks(store)进行配置,在客户端使用CallToolWithPollingAsync或CallToolAsTaskAsync。 -
客户端从 initialize 握手开始
v2 优先使用2026-07-28版本并自动回退;仅在需要严格行为时,才设置McpClientOptions.ProtocolVersion固定版本。
包与目标框架
这个 SDK 最不显眼但影响最深远的改进之一,就是它的覆盖范围。v2 包的目标框架包括 net8.0、net9.0、net10.0,以及 netstandard2.0(用于 .NET Framework)。包以一个小而可组合的集合形式提供。大多数服务器从 ModelContextProtocol 开始;如需 Streamable HTTP 服务器,则引入 ModelContextProtocol.AspNetCore;如果只需要客户端或底层构建块,仅安装 ModelContextProtocol.Core 即可。
| 包 | 用途 |
|---|---|
| ModelContextProtocol.Core | 客户端及底层服务端;依赖最小 |
| ModelContextProtocol | Stdio 服务端、托管/DI 及基于特性的发现。从这里开始 |
| ModelContextProtocol.AspNetCore | Streamable HTTP 服务端 |
| ModelContextProtocol.Extensions.Tasks | 支持客户端轮询和可插拔持久化的长时运行工具(可选) |
| ModelContextProtocol.Extensions.Apps | 交互式、服务端交付的 UI(实验性;MCPEXP003) |
大多数服务器:
正式发布 MCP C# 官方 SDK v2.0
dotnet add package ModelContextProtocol
HTTP 服务器:
dotnet add package ModelContextProtocol.AspNetCore
标题:官方 MCP C# SDK v2.0 正式发布
dotnet add package ModelContextProtocol.Core
Core 包还自带 Roslyn 分析器,能在编译时自动捕获常见错误,这意味着文章里提到的很多规范,直接在编辑器里就能帮你把关。
扩展:Apps 和 Tasks
你可能注意到了,上表中还有两个包并不属于基础 SDK:ModelContextProtocol.Extensions.Apps 和 ModelContextProtocol.Extensions.Tasks。这并非打包时的意外,而是刻意为之的架构设计。
2026-07-28 修订版把扩展提升为头等概念,通过核心协议之外的 capabilities 来协商。MCP C# SDK 完全贯彻了这一思路:Apps 和 Tasks 并没有内置到基础 SDK 中,而是以独立包的形式附着在 ModelContextProtocol.Core 之上,并且严格按需启用。你只在需要的时候才引入它们,基础 SDK 则保持精简。这和“无状态”原则一样,都是“按需付费”——只依赖你要用的东西。
MCP Apps 让服务器能在支持该功能的客户端中交付交互式 UI(对应 SEP-1865)。启用方式是通过 .WithMcpApps() 方法,并在提供 UI 资源的工具上添加注解。MCP Apps 目前是实验性功能,使用前需要抑制诊断 MCPEXP003。
MCP Tasks 支持长时间运行的工具执行,配合客户端轮询和可插拔的持久化机制(对应 SEP-2663)。入门方式很简单,调用 .WithTasks(new InMemoryMcpTaskStore()),然后 MRTR(多轮工具调用)就通过任务存储来流转,这样长时间运行的工具也能请求用户输入。InMemoryMcpTaskStore 仅供开发和测试使用;如果你的任务需要跨进程重启或跨服务器实例工作,就应当基于持久化共享存储来实现 IMcpTaskStore 接口。
MCP Apps 和 Tasks 各自都值得单独写一篇介绍,但这里重点在于架构层面的意图:SDK 被刻意设计成这种“按需选择”的附加组件形态。随着 MCP 概念不断涌现和演进,我们正在努力保持 ModelContextProtocol 和 ModelContextProtocol.Core 这两个包的轻量性,将它们限制在基础协议内建的行为范围内。