1. 为什么你的 Agent 调不动远程工具:McpClientTool 桥接场景拆解
如果你已经用 AIFunction 在本地跑通过工具调用,接下来大概率会撞上同一堵墙:工具逻辑不在本机,而是跑在另一个进程、另一台机器上的 MCP 服务里。这时候你手里只有一个 MCP 服务地址,Agent 却完全不知道那边有哪些工具、参数长什么样、返回什么结构。McpClientTool 就是解决这个断层的东西——它是 MCP 工具化桥接器,把远程 MCP 服务暴露的工具包装成 AIFunction,让 Agent 的调用链完全不用改。
先说清楚它是什么。McpClientTool 继承自 AIFunction,也就是说对 Agent 而言,它和你在本地写的那个绑定方法的 AIFunction 没有区别。区别在内部:本地 AIFunction 的 InvokeCoreAsync 直接执行你的 C# 方法,而 McpClientTool 的 InvokeCoreAsync 会走 MCP 协议,把参数序列化后发给远程 MCP 服务,等对方执行完再把结果反序列化回来。整个过程对 Agent 透明。
它能做什么?三件事最关键。第一,自动发现工具。你不需要手写每个工具的 schema,McpClient 的 ListToolsAsync 会把远程服务的工具列表拉回来,每个工具就是一个 McpClientTool,名称、描述、输入输出 JSON Schema 全部带回来。第二,无缝接入调用链。这些 McpClientTool 直接塞进 Agent 的 tools 参数,模型看到的就是标准 AIFunction 列表,该调哪个调哪个。第三,支持进度通知和元数据改写。长任务可以通过 IProgress 接收进度,工具名和描述还能用 WithName、WithDescription 临时改掉,适配不同 Agent 的语义。
适合谁?适合已经在用 MAF(Microsoft Agent Framework)或 Microsoft.Extensions.AI 搭 Agent、并且工具逻辑需要独立部署的人。典型场景是:天气查询、地理位置解析、数据库查询、内部 API 封装这类工具,你希望它们作为独立 MCP 服务跑着,多个 Agent 共享,而不是每个 Agent 项目里复制一份 C# 代码。这种时候 McpClientTool 就是那座桥。
我试过的坑是:一开始以为 ListToolsAsync 返回的是普通对象,直接当字典用,结果发现每个元素都是 AIFunction 子类,得按 AIFunction 的方式注册。这个认知差是后面所有配置的基础。
2. TaoToken 前置:给桥接链路准备一个稳定的模型出口
McpClientTool 解决的是工具侧的问题,但整条链路要跑通,模型侧也得有个能用的出口。Agent 的 RunAsync 最终要调模型,模型要能理解工具 schema 并决定调哪个工具。这一步如果模型接口不稳定,你会误以为是 McpClientTool 桥接失败,其实是模型根本没返回 tool_calls。
TaoToken 在这里的角色是提供一个兼容 OpenAI 接口的模型访问入口。它的 Base URL 是https://taotoken.net/api,你拿到的 API Key 直接填进 OpenAIClient 就行。为什么要在讲 McpClientTool 之前先提这个?因为后面验证桥接是否生效时,你需要一个确定的模型来产生工具调用决策。如果模型侧配置错了,报错信息会混在一起,排查成本翻倍。
具体要准备三样东西。第一,API Key。去https://taotoken.net/api-keys创建一个,注意这个 Key 只在创建时完整显示一次,复制好。第二,Base URL。就是上面那个https://taotoken.net/api,注意不要多加/v1之类的后缀,OpenAIClient 的 Endpoint 配置方式不同,后面代码里会写清楚。第三,Model ID。这个取决于你想用哪个模型,在模型对话页面能看到可用列表,选一个支持 function calling 的。https://taotoken.net/models这个入口可以快速试模型对话,确认模型能正常响应。
这里有个容易踩的点:OpenAIClient 的 Endpoint 和很多 SDK 的 base_url 语义不一样。在 .NET 的 OpenAI SDK 里,OpenAIClientOptions.Endpoint要的是完整的基础地址,SDK 会自己在后面拼/chat/completions这类路径。所以如果你填了https://taotoken.net/api/v1,实际请求可能变成https://taotoken.net/api/v1/chat/completions,而正确的应该是https://taotoken.net/api/chat/completions。这个差异在 401 或 404 报错时特别容易混淆。
另外,如果你打算长期跑编码类 Agent,或者工具调用频率很高,可以了解一下 Coding Plan,它在持续调用场景下更省心。入口在https://taotoken.net/coding-plan。但如果你只是先验证 McpClientTool 桥接,用按量计费的 API Key 就够了,不用一上来就上套餐。
把模型出口准备好之后,整条链路就是:Agent 收到任务 → 模型决定调哪个工具 → McpClientTool 把调用转发给 MCP 服务 → MCP 服务执行 → 结果回传 → 模型生成最终回答。McpClientTool 负责的是中间那段转发,但两端的配置都得对。
3. 可复制配置:MCP 服务端连接参数与 AIFunction 注册片段
这一节直接给能跑的配置。分三块:MCP 服务端怎么起、客户端连接参数怎么写、McpClientTool 怎么注册进 Agent。
先看 MCP 服务端。用 FastMCP 起一个带两个工具的 HTTP 服务,端口 3721,传输方式用 streamable HTTP。服务端代码里工具定义和普通 FastMCP 没区别,关键是mcp.run的参数:
from fastmcp import FastMCP from typing import Callable, Any from requests import Response from dotenv import load_dotenv import requests, os load_dotenv() mcp = FastMCP("weather-forecast") def invoke(url: str, params: dict, extract_result: Callable[[Response], Any]) -> Any: response = requests.get( headers={"X-QW-Api-Key": os.getenv("QW_API_KEY")}, url=url, params=params ) if response.status_code == 200: return extract_result(response) else: raise Exception(f"请求失败,状态码:{response.status_code}") @mcp.tool() def look_up_location(city: str) -> str: """查询指定城市的地理位置 Args: city (str): 城市名称,例如 "北京" 或 "beijing" """ return invoke( url=os.getenv("QW_LOCATION_LOOKUP_URL", ""), params={"location": city}, extract_result=lambda response: response.json()["location"][0]["id"] ) @mcp.tool() def get_weather(location: str) -> dict: """获取指定位置的实时天气信息 Args: location (str): 工具 look_up_location 返回的指定城市的地理位置 """ return invoke( url=os.getenv("QW_WEATHER_URL", ""), params={"location": location}, extract_result=lambda response: response.json()["now"] ) mcp.run(transport="http", host="0.0.0.0", port=3721, stateless_http=True)注意stateless_http=True这个参数。它让服务端不维护会话状态,每次请求独立处理。对于工具调用场景这通常够用,而且省去了会话管理的复杂度。如果你的工具需要跨调用保持状态,才需要关掉它。
客户端连接参数。核心是HttpClientTransportOptions,Endpoint 指向 MCP 服务的/mcp路径:
using ModelContextProtocol.Client; var options = new HttpClientTransportOptions { Endpoint = new Uri("http://localhost:3721/mcp"), }; var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(options)); var tools = await mcpClient.ListToolsAsync();这里tools的类型是IList<McpClientTool>,每个元素都是 AIFunction 子类。你可以直接把它展开注册进 Agent:
using DotNetEnv; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; using OpenAI; using System.ClientModel; Env.Load(); var openaiUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL")!; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!; var model = Environment.GetEnvironmentVariable("MODEL")!; var options = new HttpClientTransportOptions { Endpoint = new Uri("http://localhost:3721/mcp"), }; var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(options)); var tools = await mcpClient.ListToolsAsync(); var openAIClient = new OpenAIClient( new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri(openaiUrl) }); var agent = openAIClient .GetChatClient(model) .AsIChatClient() .AsAIAgent(tools: [.. tools]); var response = await agent.RunAsync("根据目前北京天气提供一些着装建议"); Console.WriteLine(response);这段代码里三件套齐全:Base URL 是openaiUrl,从环境变量读,值应该是https://taotoken.net/api;Key 是apiKey;Model ID 是model。MCP 服务端的连接参数是http://localhost:3721/mcp。两边都配好,桥接才能通。
如果你用settings.json或appsettings.json管理配置,可以写成这样:
{ "OpenAI": { "BaseUrl": "https://taotoken.net/api", "ApiKey": "sk-你的Key", "Model": "gpt-4o-mini" }, "McpServer": { "Endpoint": "http://localhost:3721/mcp" } }注意 BaseUrl 不要带/v1,原因前面说过。McpServer 的 Endpoint 要带/mcp,这是 FastMCP 的默认路径。
4. 验证请求:一次工具调用往返的完整动作与预期输出
配置写完不算完,得验证桥接真的生效了。验证分两步:先确认工具列表拉回来了,再确认 Agent 能通过 McpClientTool 调通远程工具。
第一步,查看 MCP 工具。写个小程序把 ListToolsAsync 的结果打印出来:
using DotNetEnv; using ModelContextProtocol.Client; using System.Text.Json; var options = new HttpClientTransportOptions { Endpoint = new Uri("http://localhost:3721/mcp"), }; var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(options)); var tools = await mcpClient.ListToolsAsync(); var serializerOptions = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; foreach (var tool in tools) { Console.WriteLine($""" {new string('-', 20)}{tool.Name}{new string('-', 20)} Description: {tool.Description} JsonSchema: {JsonSerializer.Serialize(tool.JsonSchema, serializerOptions)} ReturnJsonSchema: {JsonSerializer.Serialize(tool.ReturnJsonSchema, serializerOptions)} """); }预期输出里应该能看到look_up_location和get_weather两个工具,每个都带 Description、JsonSchema 和 ReturnJsonSchema。JsonSchema 里city是 required 的 string,ReturnJsonSchema 里result是 string。这些和你在 MCP 服务端定义的一致,说明工具发现这步通了。
第二步,跑一次完整调用。用前面那段 Agent 代码,任务写「根据目前北京天气提供一些着装建议」。预期输出类似:
北京目前天气为雾,气温24°C,体感约27°C,湿度很高(97%),能见度一般,北风较弱。 这种天气建议穿得轻薄、透气一些: - 上衣适合短袖、薄衬衫、速干T恤等透气材质。 - 下装可以选择薄款长裤、休闲裤或短裤。 - 湿度高,体感会有些闷,尽量避免厚重或不透气的衣物。 - 早晚如果长时间待在空调房,可以备一件轻薄外套。 - 目前有雾、能见度较低,如果夜间外出或骑行,建议穿稍微亮色一点的衣服,更容易被看见。 另外,空气潮湿时鞋子容易闷,运动鞋或透气凉鞋会更舒服。看到这个输出,说明整条链路通了:模型决定调look_up_location拿北京的位置 ID,再调get_weather拿天气,最后生成建议。McpClientTool 在中间完成了两次远程调用转发。
如果你想更直观地确认工具调用发生了,可以在 MCP 服务端加日志,或者在客户端用WithProgress接收进度。对于长任务,进度通知是验证桥接的另一个信号:
var longRunningTool = tools.Single(t => t.Name == "long_running_task"); var progress = new Progress<ProgressNotificationValue>(update => { Console.WriteLine($"{update.Message}: {update.Progress}/{update.Total}"); }); await longRunningTool.CallAsync(progress: progress);预期输出是逐步打印Step 1 completed: 1/5到Step 5 completed: 5/5。这说明 McpClientTool 的 CallAsync 不仅转发了调用,还正确接收了服务端通过 MCP 协议发回的进度通知。
验证通过的标准很简单:工具列表能拉到、Agent 能调通、结果符合预期。三个都满足,桥接就是生效的。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 报错对照
桥接不生效时,报错信息往往指向不同层。这一节按真实报错对照排查。
401 Unauthorized。这个最常见,但来源可能有两个。如果报错信息里提到 OpenAI 或 chat completions,那是模型侧的 Key 问题。检查OPENAI_API_KEY环境变量是否设置、是否有多余空格、Key 是否过期。如果报错提到 MCP 或 tool call,那是 MCP 服务端的认证问题。FastMCP 默认不启用认证,但如果你加了 auth 中间件,客户端连接时就得带 token。HttpClientTransportOptions 里可以配 AdditionalHeaders 传认证头。
local proxy failed。这个报错通常出现在客户端连不上 MCP 服务端时。先确认 MCP 服务真的在跑:curl http://localhost:3721/mcp看有没有响应。如果服务在 Docker 里,localhost 可能不通,得用容器网络地址。另外检查端口有没有被占用,3721 被占的话换个端口,两边同步改。
reading choices 相关报错。这个一般出现在模型返回结构不符合预期时。比如模型返回了 tool_calls 但格式不对,或者模型根本不支持 function calling。排查方法:先用模型对话页面单独测一下这个模型能不能正常返回 tool_calls。如果模型不支持,换一个支持 function calling 的 Model ID。另外确认 Base URL 没写错,https://taotoken.net/api后面不要加/v1,加了会导致请求路径错误,返回的可能是 HTML 而不是 JSON,解析时就报 reading choices 的错。
OAuth 报错。如果你在 MCP 服务端启用了 OAuth,客户端连接时会走授权流程。报错可能是 token 过期、scope 不对、或者回调地址不匹配。排查时先看服务端的 OAuth 配置,确认 client_id、client_secret、授权端点都对。客户端这边,HttpClientTransportOptions 需要配置对应的认证方式。如果只是本地验证,建议先关掉 OAuth,用无认证模式跑通桥接,再逐步加认证。
工具列表为空。ListToolsAsync 返回空列表,但服务端明明定义了工具。检查 MCP 服务端的 transport 配置,stateless_http=True在某些 FastMCP 版本下需要配合正确的路径。另外确认客户端 Endpoint 是http://localhost:3721/mcp而不是http://localhost:3721,少了/mcp路径会连到根路径,拿不到工具列表。
调用超时。McpClientTool 的 CallAsync 默认超时可能不够长任务用。可以在 RequestOptions 里设置更长的超时,或者在 HttpClientTransportOptions 里配置 HttpClient 的超时。对于长任务,配合进度通知使用,避免误判为卡死。
排查顺序建议:先确认 MCP 服务端能独立响应(用 curl 或 MCP Inspector),再确认客户端能拉到工具列表,最后确认 Agent 能调通。每一步单独验证,比一上来就跑完整链路更容易定位问题。
6. 从桥接到生产:McpClientTool 的长期使用建议
跑通验证之后,下一步是怎么在真实项目里稳定用。几个实际经验。
工具命名冲突。多个 MCP 服务可能提供同名工具,注册进同一个 Agent 时会冲突。McpClientTool 提供了WithName方法,可以在注册前改掉工具名,加个前缀区分来源。比如weather_look_up_location和map_look_up_location。注意改完名字后,模型看到的就是新名字,描述也可以同步用WithDescription调整。
进度通知的消费。长任务场景下,IProgress 的回调是在调用线程上执行的,如果回调里做耗时操作会阻塞。建议回调里只做轻量记录,比如写日志或更新 UI 状态,重活丢到队列里异步处理。
连接复用。McpClient 创建一次可以复用,不需要每次调用都 CreateAsync。在 Agent 生命周期内保持一个 McpClient 实例,工具列表也缓存起来,避免频繁拉取。如果 MCP 服务端的工具会动态变化,再考虑定期刷新。
错误处理。McpClientTool 的 CallAsync 在远程调用失败时会抛异常,Agent 的 RunAsync 可能会把异常包装后返回。建议在 Agent 层面加一层错误处理,把工具调用失败的信息透传给模型,让模型决定是重试还是换工具。直接让异常冒泡到用户界面体验很差。
配置管理。Base URL、API Key、Model ID、MCP Endpoint 这些不要硬编码在代码里。用环境变量或配置文件管理,不同环境(开发、测试、生产)用不同配置。特别是 API Key,不要提交到代码仓库。
如果你打算把这条链路用到编码类 Agent 或长期运行的自动化任务上,Coding Plan 在持续调用场景下比按量计费更可控。入口在https://taotoken.net/coding-plan。接入文档在https://taotoken.net/doc,里面有各语言 SDK 的配置示例。API Key 管理在https://taotoken.net/api-keys,可以创建多个 Key 分配给不同环境。
最后一点:McpClientTool 是桥,不是替代品。它不负责工具逻辑的实现,也不负责模型的决策。它的职责边界很清楚——把远程 MCP 工具翻译成 AIFunction,让 Agent 的调用链无感接入。理解这个边界,配置和排查时就不会跑偏。