官方 MCP C# SDK 发布 v2.0(翻译)
官方 MCP C# SDK 发布 v2.0
原文:Announcing v2.0 of the official MCP C# SDK(Jeff Handley,2026-07-28,.NET Blog)
由 DotCraft 翻译。
模型上下文协议 (MCP) C# SDK 已发布 v2.0,实现了 2026-07-28 版 MCP 规范。这是该协议自推出以来最大的一次修订。
这次发布与以往不同。此前的更新是在协议现有形态之上叠加能力。而 2026-07-28 修订回归到基础层面,重新思考了 MCP 如何通过 HTTP 工作。它让协议默认无状态(stateless),将 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 修订是一组协调一致的规范增强提案(SEP)。完整的变更清单请参阅规范变更日志。关于设计背后的叙述,MCP 维护者的 2026-07-28 公告是很棒的补充阅读材料。
下面是新特性一览。
默认无状态
在之前的规范中,通过 Streamable HTTP 调用工具需要先完成 initialize 握手,并且在使用 v1 SDK 默认配置时需要建立会话。服务器会返回一个 Mcp-Session-Id,客户端必须在后续每次请求中携带它,将其固定到签发该 ID 的服务器实例上。水平部署需要粘性会话路由或会话迁移才能正常工作。
2026-07-28 修订用自包含请求取代了这种连接级设置:initialize/initialized 握手被移除(SEP-2575),Mcp-Session-Id 请求头也被移除(SEP-2567),协议版本和能力现在随每个请求一起传递。
实际效果是:任何服务器实例都能处理任何请求,因此水平部署所需的粘性会话和共享会话存储不再需要出现在协议层。Serverless、多实例和边缘部署直接就能用。
SDK 与规范保持同步:HTTP 服务器传输现在默认以无状态方式运行。v1 默认配置有状态会话,而 v2 中 HttpServerTransportOptions.Stateless 默认为 true。
using ModelContextProtocol.Server;
using System.ComponentModel;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport() // stateless by default now
.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 服务器就是一个普通的 ASP.NET Core 应用,所以常用的多阶段 Dockerfile 就足以把它部署到任何能运行容器的地方。
"无状态协议" 并不意味着 "无状态应用"。如果你的服务器需要在多次调用之间携带状态,就像 HTTP API 一直以来做的那样:从一个工具中生成一个显式句柄(如 basketId、browserId),然后让模型在后续调用中作为普通参数把它传回来。事实证明,模型在调用之间传递一个标识符,往往比隐藏在传输元数据中的会话状态更强大:模型可以在多个工具之间组合句柄、对其进行推理,并在步骤之间进行交接。
选择使用会话
无状态是默认值,而不是强制要求。如果你确实需要服务器主动发消息给客户端,或需要会话级的传输状态,你仍然可以选择有状态模式。由于会话现在是可选加入的,旧的 SSE 端点和一些仅限有状态的选项默认关闭或已废弃(诊断 MCP9004 和 MCP9006),所以如果你依赖旧行为,会收到友好提示。原则是"按需付费":只有真正使用会话复杂度时才承担它。
默认无状态还有一个更深层的原因,关乎向前看而不只是横向扩展。因为 2026-07-28 线格式彻底移除了 initialize 握手和 Mcp-Session-Id,运行 Stateless = true 是面向未来兼容的选择:它让你的服务器能直接、原生地以新协议回答 2026-07-28 客户端。旧客户端也不会被抛弃(服务器仍会为它们回退到旧的握手),但新客户端能得到没有阻碍的现代路径。
基于纯 HTTP 构建,天然可扩展
无状态化改变了 MCP 请求的形态:它现在是一个单一的、自描述的 HTTP POST。这打开了一扇以前无法走进去的门:你现有的 HTTP 基础设施终于可以把 MCP 当成普通流量来处理。无需 sidecar、无需解析 body、无需特殊处理。
2026-07-28 修订标准化了一小组 HTTP 请求头,它们镜像了中间层真正关心的字段(SEP-2243)。一次 tools/call 现在会携带 Mcp-Method: tools/call 和 Mcp-Name: get_order_status,以及 JSON-RPC body。因此负载均衡器、代理、网关、WAF 或可观测性工具无需深度包检测就能处理 MCP 流量。而且只需一个属性,你就可以把任何工具参数提升为 Mcp-Param-* 请求头。
这正是地理分布式路由所需要的。设想一个调用后端服务(比如订单服务)的工具,它部署在多个区域。该工具接受 region 和 orderId,而全局负载均衡器需要把每次调用发送到就近的区域部署。把 region 提升为请求头,路由器就能直接据此分发,而无需读取请求 body:
[McpServerTool(Name = "get_order_status",
Description = "Gets order status from the regional orders service")]
public static async Task<string> GetOrderStatus(
OrdersServiceClient orders,
[McpHeader("Region"), Description("Orders service region")] string region,
[Description("The order to look up")] string orderId)
{
// The client mirrors `region` into a request header:
// Mcp-Param-Region: eastus2
return await orders.GetStatusAsync(region, orderId);
}
[McpHeader] 属性会标注该参数,并向工具输入 schema 发出一个 x-mcp-header 关键字,这样客户端就知道在传输时把该参数提升到请求头中。这些标准化请求头的设计目标,读起来就像写给每个曾在代理后面运行服务的人的情书。它们的设计目标是:
- 在每次请求中把 method、name 和选定的参数镜像到请求头里。
- 不检查 body 的任何内容:中间层仅凭请求头进行路由。
- 让 JSON-RPC body 保持权威性:服务器会拒绝任何与之冲突的请求头。
- 使用 Base64 哨兵安全地编码非 ASCII 值,让请求头保持干净。
第三个目标关乎正确性。body 永远是事实来源;如果请求头和 body 不一致,服务器会拒绝该请求而不是去猜测,并返回 HeaderMismatch 错误。整个功能是加法式且非破坏性的:客户端在 Streamable HTTP 上发送这些请求头,但只有当双方都使用 2026-07-28 时服务器才会强制校验,所以你现在已有的东西都不会坏。
这类功能往往只有事后看起来才显得显而易见,它完美契合 ASP.NET Core——在那里请求头、路由和中间件就是原生的词汇。而且,请允许我带上一点感情:这是我特别喜欢的一个功能——提出它的人同时写了第一个 .NET 实现,当提案和原型出自同一双手时,效果立现。
请求用户输入
到目前为止,无状态的故事全是利好消息。但这里有一个小问题,值得坦白说出来,因为这正是 v2 要解决的问题。
有些工具无法在单次往返中给出答案。一个重要的操作想先和用户确认。一个摘要工具想让客户端的 LLM 先起草内容。一个文件工具想知道它被允许触碰哪些工作区根目录。在旧世界里,这三类情况(elicitation 诱导、sampling 采样、roots 根目录)都是服务器发起的请求:服务器在调用中途回连客户端并等待答复。
这种模式只在实时、有状态的会话下才能工作。它需要一个从服务器到客户端的持久通道,而这正是无状态模型所放弃的东西。所以,让 MCP 优美扩展的东西(每个请求落在任意实例上)也正是让交互式工具变得不可能的东西。
如果故事到此为止,"无状态" 就要带个星号:扩展很好,但你失去了交互性。可故事并没有到此结束。
多轮往返请求
多轮往返请求(MRTR)就是答案,也是 2026-07-28 修订的重头戏(SEP-2322)。它不再通过会话让服务器回连客户端,而是让服务器返回一个结果,其含义是"我需要你先给我点东西"。
具体来说,工具返回一个 InputRequiredResult(其 resultType 为 "input_required"),携带一个或多个输入请求和一个不透明的 requestState blob。客户端满足这些输入(提示用户、调用其 LLM、列出根目录),然后带着收集到的 inputResponses 和回显的 requestState 重新发起同一次 tools/call。这可以重复多轮,而且关键在于:它完全不需要会话,因为所有连续性都在负载中传递。
服务器端对 MRTR 的支持
在服务器端,你从工具中抛出 InputRequiredException,传入你需要的输入和一个 requestState 字符串(它会在重试时回显给你)。你用工厂方法 InputRequest.ForElicitation(...)、InputRequest.ForSampling(...) 和 InputRequest.ForRootsList(...) 构建各个请求。
下面是一个在完成重要操作前先确认的工具:
[McpServerTool, Description("Closes a support ticket, recording why it was closed.")]
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
// to confirm (or adjust) it. This uses the 2026-07-28 MRTR input request, but the SDK
// provides an automatic bridge to a legacy elicitation on a down-level, stateful
// session. When the bridge can be provided, `server.IsMrtrSupported` is `true` and the
// exception leads to a legacy elicitation response automatically.
if (server.IsMrtrSupported)
{
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["closeReason"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"Close ticket '{ticketId}'? Accept the default reason or provide your own.",
RequestedSchema = new()
{
Properties =
{
["closeReason"] = new ElicitRequestParams.StringSchema
{
Title = "Close reason",
Description = "The reason for closing the ticket",
Default = defaultCloseReason,
},
},
},
})
},
requestState: ticketId.ToString()); // opaque; echoed back to us on the retry
}
// (4) Down-level and stateless: we can't prompt an elicitation through an MRTR
// round-trip request or an elicitation. Return a natural language response
// with guidance for providing the reason up-front.
return "Closing a ticket requires a reason. Resend with `closeReason`.";
}
一个方法,服务所有客户端。这个工具能在两种情况下进行往返:面对 2026-07-28 客户端(原生 MRTR),或面对有状态会话上的低版本客户端(此时 SDK 会把同一次抛出桥接到旧的诱导机制)。在两种情况下,它都会提出一个默认关闭原因,并在从 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) =>
{
// Accept the proposed default close reason.
return ValueTask.FromResult(new ElicitResult { Action = "accept" });
},
}
});
// The client transparently handles the input_required round trip.
var result = await client.CallToolAsync(
"close_support_ticket",
new Dictionary<string, object?> { ["ticketId"] = 1234L },
cancellationToken: CancellationToken.None);
一种模式,取代三种
因为 MRTR 泛化了"服务器需要从客户端拿点东西"的交互,它取代了之前在无状态服务器上针对 induction、sampling 和 roots 的服务器发起请求模式。
诱导(Elicitation)现在通过 InputRequiredException 和 InputRequest.ForElicitation(...) 实现。旧的 ElicitAsync 方法在有状态会话上继续可用,但在无状态模式下会抛出异常,因为没有会话可以承载服务器发起的请求。
针对安全、带外同意的特殊场景(第三方 OAuth、敏感数据),v2 新增了 UrlElicitationRequiredException,用于无状态流程中的 URL 模式诱导。客户端展示一个服务器托管的 URL,在带外收集同意,然后重试。更多细节请参阅 URL 模式诱导(带外) 文档。
采样(Sampling)和根目录(Roots)在诊断 MCP9005 下被废弃,以对齐 SEP-2577。SampleAsync 和 RequestRootsAsync 仍然可用,但在无状态模式下也会抛出异常。
日志(Logging)也作为 SEP-2577 的一部分被废弃,因为它与 stderr 和 OpenTelemetry 有重叠。对这些 API 的引用会产生 MCP9005 警告。
MRTR 与向后兼容
MRTR 设计为优雅降级。以下是它在不同协议版本和会话模式下的行为:
| 协商协议 | 会话模式 | MRTR 行为 |
|---|---|---|
| 2026-07-28 | 无状态 | 原生:无需服务器端处理器状态。 |
| 2026-07-28 | 有状态 | 原生:InputRequiredResult 直接序列化到线路上。 |
| 2025-11-25 及更早 | 有状态 | 向后兼容解析器:SDK 桥接到旧的基于会话的请求。 |
| 2025-11-25 及更早 | 无状态 | 不支持:输入请求以 McpException 的形式浮出。 |
第三行正是为什么上面的 CloseSupportTicket 工具不需要低版本变体:当 v2 服务器通过有状态会话与旧客户端对话时,SDK 会自动把 MRTR 桥接到旧的服务器发起请求模式。第四行是唯一完全无法提示的情况(无会话的低版本客户端),这正是为什么该工具还接受可选的 closeReason 参数,让那个调用方能走一条非交互式的通道而不是死路一条。
设计上的向后兼容
现在来兑现文章开头的承诺。主版本号提升可能会让人不安,所以让我们具体说明这里的"向后兼容"意味着什么。
你的 v1 代码继续可用。稳定且未废弃的 1.x API 在 2.0 中继续编译和运行。本次发布引入的弃用项(针对服务器发起请求的 MCP9005、针对仅限有状态选项的 MCP9006、针对旧 SSE 的 MCP9004)是警告,不是移除。你可以按自己的节奏迁移。
旧客户端和服务器双向继续工作。v2 客户端在与低版本服务器对话时会透明地使用旧的 initialize 握手,而 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 预览版,这是唯一需要规划迁移的地方。好消息是新设计好得多。下文详述。
一些迁移要点:
| v1 | v2 |
|---|---|
| HTTP 传输默认为有状态 | 接受新的无状态默认值,或在需要会话时显式设置 Stateless = false |
实验性 Tasks 通过 McpServerOptions.TaskStore 和 McpClientOptions.TaskStore 内置于 Core |
添加 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) |
# Most servers:
dotnet add package ModelContextProtocol
# HTTP servers:
dotnet add package ModelContextProtocol.AspNetCore
# Just a client, or the low-level API:
dotnet add package ModelContextProtocol.Core
Core 包还捆绑了 Roslyn 分析器,能在构建时捕获常见错误,因此本文中很多指导直接在编辑器中就得到强制执行。
扩展:Apps 和 Tasks
你可能注意到表里有两个不属于基础 SDK 的包:ModelContextProtocol.Extensions.Apps 和 ModelContextProtocol.Extensions.Tasks。这不是打包上的巧合,而是架构使然。
2026-07-28 修订让扩展成为一等公民,通过核心协议之外的能力进行协商。MCP C# SDK 充分拥抱了这个理念:Apps 和 Tasks 没有硬编码进基础 SDK。它们作为独立包发布,挂在 ModelContextProtocol.Core 之上,并且严格可选加入。你只在需要时才添加它们,基础 SDK 保持精简。这和"无状态"背后的"按需付费"原则一致:只依赖你用到的部分。
- MCP Apps 让服务器能在支持的客户端中交付交互式 UI(SEP-1865)。你通过
.WithMcpApps()启用它,并标注提供 UI 资源的工具。MCP Apps 是实验性的,其 API 需要压制 MCPEXP003 诊断。 - Tasks 支持带客户端轮询和可插拔持久化的长期运行工具执行(SEP-2663)。你可以用
.WithTasks(new InMemoryMcpTaskStore())起步,MRTR 会流经任务存储,让长期运行的工具也能请求输入。InMemoryMcpTaskStore仅用于开发和测试;对于必须跨进程重启或在多个服务器实例间工作的事务,请实现IMcpTaskStore并使用持久化的共享存储。
MCP Apps 和 Tasks 各自都值得单独成文,但就当前而言,重点是架构性的:这个 SDK 刻意构建成让这些能力成为你可选加入的附加项。随着 MCP 概念不断涌现和演进,我们努力让 ModelContextProtocol 和 ModelContextProtocol.Core 保持精简,只包含内置于基础协议的行为。
接下来做什么
随着 2026-07-28 规范合规落地于 2.0,SDK 的下一个重点是端到端的身份验证和授权:让安全的 MCP 部署像栈中其他部分一样开箱即用,这建立在规范引入的更紧密的 OAuth 和 OpenID Connect 对齐之上。这项工作将在 2.x 系列中继续,你可以公开跟进。
总结
MCP C# SDK 的 2.0 版本是在 .NET 上构建 MCP 服务器和客户端的一个里程碑。MCP 已为 Web 而成熟(默认无状态、可被普通基础设施路由、凭借多轮往返请求无需会话也能交互),而 .NET 已为此做好准备——它依托 ASP.NET Core,并一路回溯支持到 .NET Framework。最棒的是,你无需放弃现有的客户端、服务器或代码就能实现这一切。
如果你正在用 .NET 构建 MCP 服务器或客户端,现在是升级的好时机。
有疑问、bug 或想法?联系团队的最佳地点是 csharp-sdk 仓库:提出 issue 或发起讨论,并花点时间浏览 2026-07-28 规范变更日志 以获取 MCP 协议规范变更的完整清单。当你准备好深入探索时,包和完整的 2.0.0 发布说明 就是起点。我们迫不及待想看看你构建出什么。
原文:Announcing v2.0 of the official MCP C# SDK,作者 Jeff Handley,2026-07-28。

浙公网安备 33010602011771号