.NET消费MCP服务三动词:Connect/Discover/Call
.NET应用作为MCP客户端通过stdio或HTTP连接服务器,手动编写的60行Agent循环可压缩为三个动词,附Aurora Coffee Co.完整示例。
完整中文译文
协议的客户单侧
一篇讲服务器的文章说的是暴露能力。讲客户端的文章是它的镜像,只需要三个动词:
连接——通过某种传输层。要么把服务器作为子进程启动(stdio),要么指向一个 URL(HTTP)。
发现——询问服务器有什么。工具及其输入 schema 作为数据到达,没有什么是被编译进代码里的。
调用——通过名称和一组参数调用工具,并接收返回的内容。
值得在第三步停下来,因为它和 tool use 那篇文章看到的安全边界是同一枚硬币的反面。之前,你的代码拥有执行权,Claude 只能发起请求。现在发起请求的是你,服务器拥有执行权。你发送一个名称和参数;实际执行什么完全由服务器决定。
发现这一步是将它与简单调用 API 客户端区分开来的关键。你没有一个用 GetOrderStatus(string) 生成的代理类。你有一个在运行时到达的工具列表,代码必须对此感到舒适。

连接 Aurora 服务器
启动一个控制台应用并添加 SDK。如果想要最小依赖,客户端在 ModelContextProtocol.Core 里;如果同一个应用还要托管服务器,则用完整的 ModelContextProtocol 包:
dotnet new console -o AuroraCoffee.Support
cd AuroraCoffee.Support
dotnet add package ModelContextProtocol.Core
对于本地服务器,stdio 传输层将它作为子进程启动——正是之前 Claude Desktop 用那个 JSON 配置文件替你做的事,只不过现在由你来启动它:
using ModelContextProtocol.Client;
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "aurora-coffee",
Command = "dotnet",
Arguments = ["run", "--project", "../AuroraCoffee.Mcp"],
});
await using var client = await McpClient.CreateAsync(transport);
这就是全部的连接代码。McpClient.CreateAsync 启动进程、完成协议握手,并将一个可用的客户端交到你手上。注意那个 await using——客户端拥有一个子进程,不释放它就会留下一个孤儿 dotnet 进程运行着。别问我要烧掉多少散兵才能把这个教训刻进脑子里。
调用前先发现
现在这一步是人们容易跳过去、然后花一个小时调试的部分。在写任何 CallToolAsync 之前先打印工具列表:
foreach (var tool in await client.ListToolsAsync())
{
Console.WriteLine($"{tool.Name} — {tool.Description}");
}
原因不是为了走过场。SDK 从服务器端的方法名派生工具名称,所以协议暴露的确切字符串可能和你写的 C# 标识符不一样。发现才是契约;服务器端的 C# 源代码是实现细节。打印列表,复制真实的名称,然后据此写调用。用猜的名字会在运行时得到一个错误,而且这次没有编译器来救你。
调用工具
手握真实名称后,调用它就是一个名称加一个参数字典:
using ModelContextProtocol.Protocol;
var result = await client.CallToolAsync(
"get_order_status",
new Dictionary<string, object?> { ["orderId"] = "A-1001" },
cancellationToken: CancellationToken.None);
Console.WriteLine(result.Content.OfType<TextContentBlock>().First().Text);
Pedido A-1001: shipped, ETA 2026-08-03.
Content 是一个块列表,不是一个字符串,因为工具可以同时返回文本、图片或多个块。用 OfType<TextContentBlock>() 过滤是诚实读取它的方式——如果你对不返回文本的工具做 .First(),那会抛异常,所以当你不控制服务器时用 FirstOrDefault()。
注意你没有写的那些东西:没有 JSON Schema,没有 HTTP plumbing,没有消息帧。也没有引用服务器项目。Order 和 OrdersStore 在这个应用里不存在——唯一的契约就是那根线。
奖励:把工具交给 Claude
以上都是客户端手工处理工具。真正有意思的版本是让模型决定调用哪个——这正是我们在 tool use 文章里手工写循环所做的事。
McpClientTool 继承自 Microsoft.Extensions.AI 的 AIFunction。这唯一的一个继承就是全部的诀窍:从 MCP 服务器发现的工具直接进入任何 IChatClient 作为可调用函数,不需要任何适配器代码。
dotnet add package Anthropic
dotnet add package Microsoft.Extensions.AI
using Anthropic;
using Microsoft.Extensions.AI;
IChatClient chatClient = new AnthropicClient() // reads ANTHROPIC_API_KEY
.AsIChatClient("claude-opus-4-8")
.AsBuilder()
.UseFunctionInvocation()
.Build();
var tools = await client.ListToolsAsync();
var response = await chatClient.GetResponseAsync(
"¿El pedido A-1001 está enviado, y todavía te quedan ETH-250 en stock?",
new ChatOptions { Tools = [.. tools] });
Console.WriteLine(response.Text);
El pedido A-1001 ya fue enviado y está en camino para llegar el 2026-08-03. Y sí —
los granos de Etiopía (ETH-250) están en stock, con 42 unidades disponibles.
读一下这个输出,然后再回头看我们手工写的那个循环。同样的问题,同样的两次工具调用,同样的回答——只不过 while (true)、消息列表的 bookkeeping、块的重建和"原样返回 assistant turn"这个陷阱都消失了。UseFunctionInvocation() 就是替你跑那个循环的中间件:Claude 请求一个工具,它调用对应的 AIFunction,返回结果,然后重复直到有了最终回答。
工具本身运行在一个完全独立的进程里,由一个从未听说过这个应用的人写的。这一点值得静下心来好好体会。

连接远程服务器
当服务器是本地子进程时用 stdio。对于共享服务器——也就是前一篇末尾那个 ASP.NET Core 的 HTTP 版本——换一个传输层,其他不变:
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("https://tools.auroracoffee.example/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
});
await using var client = await McpClient.CreateAsync(transport);
从这往后 ListToolsAsync、CallToolAsync 和 IChatClient 的接线方式完全相同——唯一注意到差异的就是传输层。TransportMode 默认是 AutoDetect,它先尝试 Streamable HTTP,降级到遗留 SSE,所以如果你不知道另一端支持什么可以省略它。当你确实知道时明确写出来;自动检测要付出一次往返的代价。
关于前一篇以来的一些变化
如果你在之前那篇服务器文章的基础上构建,值得注意一下:C# 的 SDK 在 2026 年 7 月 28 日发布了 v2.0,实现了 2026-07-28 的规范修订版——这是 MCP 发布以来最大的一次协议变更。服务器端的属性([McpServerToolType]、[McpServerTool])和上面的客户端 API 保持不变,但有两样东西变了:
HTTP 传输层默认变成无状态(stateless)的了。HttpServerTransportOptions.Stateless 现在默认为 true,initialize 握手消失了,Mcp-Session-Id 头也没了。对在负载均衡器后面扩展共享服务器来说很棒;如果你假设了有会话,这就是一个行为变更。
服务器发起的请求(sampling、roots)在 MCP9005 诊断下被废弃,在无状态模式下会抛异常。交互式流程改成了 Multi Round-Trip Requests。
如果你的服务器做的是简单的请求/响应工具——大多数服务器都是,包括 Aurora——你不会注意到任何东西。如果依赖了会话,在升级前先读一下迁移说明。

什么时候写客户端——什么时候不写
当你的应用需要它之外的运行时能力时,写一个 MCP 客户端:平台团队维护的服务器、第三方无法控制的服务器、或者多个应用共享的自己的服务器。收益是工具列表可以在服务器端变化而无需你这边重新部署——出现一个新工具,ListToolsAsync 就返回它,如果有模型在驾驶,它就开始使用它而你一行代码都不用发。
当工具仅仅是……你自己的方法、在你自己的应用里、被你自己的模型循环调用时,跳过客户端。为了调用一个本可以直接调用的函数而经过一个协议和进程边界,完全是建筑学上的作秀。在那种场景下 tool use 的方式更简单更快,一直都是。
一般规则:工具跨越你不控制的边界时用 MCP 客户端;不跨越边界时在进程内用 tool use。
写一个客户端只需要三个动词——通过某种传输层连接,发现有什么,然后通过名称调用。其余的都是 SDK 的事。
契约是发现,不是你服务器端的 C# 代码——打印 ListToolsAsync() 并用它返回的名称。编译器无法捕获一个猜出来的工具名称。
McpClientTool 就是一个 AIFunction——唯一这个继承关系就让 MCP 工具进入任何 IChatClient,而 UseFunctionInvocation() 替代了完整的手工代理循环。
传输层是唯一因远程而改变的东西——本地子进程用 stdio,共享服务器用 HttpClientTransport;发现和调用的代码完全相同。
v2.0 改成了默认无状态——HTTP 服务器不再做 initialize 握手,sampling 和 roots 废弃了。简单工具服务器不受影响;依赖会话的需要检查一下。