同#10英文原版,.NET应用连接MCP服务器只需Connect/Discover/Call三步,60行手写Agent循环压缩至三行,附完整代码示例。
协议的客户端
服务端文章讲的是暴露能力,客户端文章是镜像——只涉及三个动词:
Connect — 通过某种传输层连接。可以把服务端作为子进程启动(stdio),也可以指向一个 URL(HTTP)。
Discover — 向服务端查询它有什么。工具及其输入 schema 作为数据返回,没有任何内容是编译进代码的。
Call — 用一组参数按名称调用工具,获取返回的内容。
第三步值得停下来仔细看,因为它和工具使用文章里的安全边界是从另一面来看的。那个时候,你的代码拥有执行权,Claude 只能发起请求。现在你是发起请求的那一方,而服务端拥有执行权。你发送一个名称和参数,实际运行什么完全取决于服务端。
Discover 这一步才是它与普通调用 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 配置为你做的事,只不过现在轮到你来 spawn 它了:
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——客户端拥有一个子进程,如果不去 dispose 它,就会留下一个 orphaned 的 dotnet 进程。想知道我花了多少个 stray 进程才记住这一点吗。
调用前先 Discover
这一步是人们常常跳过、然后花一个小时来调试的。在你写任何一个 CallToolAsync 之前,先打印工具列表:
foreach (var tool in await client.ListToolsAsync())
{
Console.WriteLine($"{tool.Name} — {tool.Description}");
}
原因不是为了仪式感。工具名称是从服务端的方法名派生的,所以协议暴露的确切字符串可能不是你输入的 C# 标识符。Discovery 才是契约;服务端的 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);
Order A-1001: shipped, ETA 2026-08-03.
Content 是一个块列表,不是字符串,因为工具可以返回文本、图片或多个块同时返回。用 OfType<TextContentBlock>() 过滤才是正确读取方式——如果你在一个没有返回文本的工具上调用 .First(),那会抛异常,所以当你无法控制服务端时,请用 FirstOrDefault()。
注意你什么都没写:没有 JSON Schema、没有 HTTP 管道、没有消息帧。你也没有引用服务端的项目。Order 和 OrdersStore 在这个应用里根本不存在——唯一的契约就是那根网线。
收获:把工具交给 Claude
上面这些都是手动驱动工具的客户端。有趣的版本是让模型来决定调用哪个——这正是我们在工具使用文章里徒手写循环做的事情。
McpClientTool 继承自 Microsoft.Extensions.AI 中的 AIFunction。这一行继承就是全部的诀窍:从 MCP 服务端发现的工具可以直接 drop 进任何一个 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(
"Is order A-1001 shipped, and do you still have ETH-250 in stock?",
new ChatOptions { Tools = [.. tools] });
Console.WriteLine(response.Text);
Order A-1001 has shipped and is on track to arrive 2026-08-03. And yes — the
Ethiopia beans (ETH-250) are in stock, with 42 units available.
读一下这个输出,然后回头看看我们徒手写的那个循环。同样的问题、两次工具调用、同样的答案——只不过 while (true)、消息列表的 bookkeeping、块的重建,以及"把 assistant 回合原封不动地 echo 回去"的坑全都没了。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 的 wiring 都是一模一样的——唯一知道不同的就是传输层。TransportMode 默认为 AutoDetect,它会尝试 Streamable HTTP 然后回退到旧的 SSE,所以如果你不知道远端支持什么,可以不写它。知道你支持什么的时候显式设置它;自动检测需要多一次往返。
自上篇文章以来的一件事
如果你是在那篇服务端文章的基础上构建的,有一点值得注意:C# SDK 在 2026 年 7 月 28 日发布了 v2.0,实现了 2026-07-28 规范修订版——这是 MCP 发布以来最大的一次协议变更。服务端属性([McpServerToolType]、[McpServerTool])和上面的客户端 API 都没变,但有两样东西移走了:
HTTP 传输默认变为无状态。HttpServerTransportOptions.Stateless 现在默认为 true,initialize 握手没了,Mcp-Session-Id 头也没了。对于把共享服务端部署在负载均衡器后面来说很棒;但如果你假设有会话,这就是一个行为变更。
服务端发起的请求(sampling、roots)在诊断 MCP9005 下已弃用,在无状态模式下会抛异常。交互式流程改为多轮请求(Multi Round-Trip Requests)。
如果你的服务端做的是普通的请求/响应工具——大多数服务端都是,包括 Aurora——你不会注意到任何区别。如果它依赖会话,请在升级前读一下迁移说明。

何时写客户端——何时不写
当你的应用需要它之外的能力时,写一个 MCP 客户端:你的平台团队维护的服务端、你无法控制的第三方服务端、或者多个应用共享的你自己的服务端。收益是工具列表可以在服务端变更而你这边不用重新部署——新工具出现,ListToolsAsync 返回它,如果是由模型驱动的,它就开始使用它而你一行代码都不用发。
当工具只是……你自己的方法、在你自己的应用里、由你自己的模型循环调用时,就跳过它。为了调用一个本可以直接调用的函数而穿越协议和进程边界,是架构 cosplay。工具使用方式在那里更简单更快,而且一直如此。
经验法则:工具跨越你不拥有的边界时用 MCP 客户端;不需要跨越边界时用普通的过程内工具调用。
客户端就是三个动词——通过传输层连接、发现那里有什么、按名称调用。其他都是 SDK 的问题。
Discovery 才是契约,不是你服务端的 C# 源码——打印 ListToolsAsync() 并使用它返回的名称。编译器抓不到猜错的工具名。
McpClientTool 就是一个 AIFunction——这一行继承意味着 MCP 工具可以直接 drop 进任何 IChatClient,而 UseFunctionInvocation() 取代了整段徒手写的 agent 循环。
远程的唯一变化是传输层——本地子进程用 stdio,共享服务端用 HttpClientTransport;发现和调用的代码完全相同。
v2.0 默认变为无状态——HTTP 服务端不再做 initialize 握手,sampling/roots 已弃用。普通工具服务端不受影响;依赖会话的需要看一下。