资讯动态

C#接入MCP协议:让ChatGPT自动调用本地工具的完整实践

发布时间:2026/10/18 3:42:46 来源:尧图企业网站定制
最近在做某个C#桌面助手项目时我遇到一个很现实的卡点ChatGPT的对话接口虽然聪明但模型本身碰不到外部数据也没法替你执行操作。想让它帮我查个天气、算个工时、读取一个本地文件都得在代码里写死一堆分支。后来我改用MCPModel Context Protocol这套协议来组织所有工具调用再用C#写客户端把ChatGPT和本地工具串起来整个结构一下子清爽了。这篇东西就是把整套方案沉淀出来的实践记录适合已经会用C#调ChatGPT接口、又想让AI真正“操作点什么”的开发者。文章会从一个最小MCP服务端开始逐步走到客户端封装、ChatGPT工具调用最后聊一聊流程里的坑和安全底线。1. MCP到底做了什么为什么C#项目越来越需要它1.1 从一个没有“手”的ChatGPT接口说起很多人第一次用ChatGPT接口时思路特别直白把用户说的话塞进请求拿到模型回复打印出来。这个模式对聊天没问题但一旦遇到“帮我查一下某个城市的天气”“把这几个文件按要求重命名”这类任务模型就卡住了因为它没有执行能力也没有外部数据。早期做法是自己在代码里写意图判断用户说包含“天气”就调用天气API包含“时间”就调用时间API。意图少还撑得住等到十几个工具时这个分支判断会变成一团乱麻而且每加一个领域都要重新改一遍对话逻辑。后来ChatGPT接口提供了Function Calling能力模型可以返回一个结构化的调用意图比如告诉你说“我想调用query_weather参数是city杭州”。这比字符串识别靠谱不少但它仍然只是一套API特性不是你业务系统里可以到处移植的标准协议。不同程序各自维护一套工具描述A系统写的是JSONB系统写的是XMLC系统直接把工具逻辑塞在prompt里维护成本很快失控。MCP出现以后模型应用和外部工具之间终于有了统一语言工具不再是某个业务代码内部的手写分支而是挂在标准协议后面的独立能力。1.2 MCP的角色划分主机、客户端、服务器MCP是Model Context Protocol的缩写中文常翻译成“模型上下文协议”。它解决的核心问题是AI应用如何以标准方式发现工具、调用工具、读取数据源。整个架构可以分成三层Host主机承载用户界面和应用主流程的程序比如桌面助手、IDE、聊天机器人。Client客户端在Host内部运行负责与MCP Server建立连接、发送请求、接收结果。Server服务器暴露一个或多个工具/资源/提示模板独立于业务之外。我习惯用一个类比ChatGPT是大脑工具是手MCP就是连接大脑和手的神经系统。大脑不关心手具体怎么运动手也不关心大脑怎么思考神经协议把两边的动作翻译成统一指令。如果换成开发语言就是模型不关心工具是谁写的、什么语言、部署在哪只要对方能理解MCP请求就能被调用。MCP底层通信基于JSON-RPC 2.0常见传输方式有两种stdio和HTTP Streamable。前者适合本地子进程比如C#主程序启动一个Python工具进程通过标准输入输出交换消息后者适合远程服务比如把工具部署成HTTP接口。本文演示用HTTP方式因为调试起来最直观你在浏览器里就能看到请求和响应。MCP里最常用的方法有这么几个initialize负责握手tools/list拉取工具列表tools/call调用具体工具resources/list枚举数据资源。真正做AI工具调用时我们通常是先把tools/list拿到的工具Schema翻译成ChatGPT能理解的Function Calling格式然后让模型决定调哪个工具再由宿主去执行tools/call。后面的完整链路就是这样跑通的。1.3 用C#接MCP的好处和成本C#接MCP最明显的好处是标准化和边界清晰。工具可以单独开发和部署主程序只需要维护一份协议客户端以后想换模型、换工具实现改动限制在很小的范围内。再加上C#有成熟的异步生态HttpClient、System.Text.Json、Microsoft.Extensions.Logging这些库正好覆盖了协议通信所需的全部基础设施。坏处也不避讳如果你手写协议要在JSON-RPC信封、协议版本协商、错误码映射上写不少样板代码。尤其System.Text.Json默认的序列化风格是PascalCase而MCP规范字段习惯用camelCase第一次跑通时很容易在大小写这个细节上栽跟头。但我的建议是早期哪怕有现成SDK也先手动实现一遍核心流程。等你亲手处理过初始化握手、tools/call请求、错误响应之后再去看SDK源码很多疑惑会瞬间解开。排查线上问题时你能直接判断是哪一层出了问题而不是对着黑盒瞎猜。2. 把地基打好开发环境与一个最小MCP服务端2.1 环境清单与项目组织开始之前你要有.NET 8 SDK和一个能编辑C#的IDE。为了保持零第三方依赖我选择只用ASP.NET Core Minimal API加System.Text.Json实现服务端客户端控制台应用用HttpClient加System.Text.Json。整个解决方案分成两个项目Demo.Mcp.ServerASP.NET Core空模板挂一个/mcp端点提供初始化、列工具、调用工具三个方法。Demo.Mcp.Client控制台应用负责和MCP Server通信并串联ChatGPT接口。为什么服务端用ASP.NET Core而不是控制台自建HttpListener因为Minimal API的开发效率高后续加鉴权、日志、限流都很顺手而且不引入任何重量级框架。MCP Server本身就是一个HTTP服务用现有Web框架承载是最自然的选择。如果你只需要本机调用不暴露到局域网可以不配置HTTPS。生产环境建议反向代理后统一走证书但这不是MCP本身的重点。2.2 用Minimal API实现JSON-RPC端点服务端只需要一个函数接收POST请求体按JSON-RPC协议解析method最后返回符合规范的响应。先定义一个最核心的处理函数使用System.Text.Json.Nodes操作JSON这样代码很直观app.MapPost(/mcp, async (JsonObject request) { var method request[method]?.GetValuestring(); var id request[id]; var response new JsonObject { [jsonrpc] 2.0, [id] id }; switch (method) { case initialize: response[result] new JsonObject { [protocolVersion] 2024-11-05, [capabilities] new JsonObject { [tools] new JsonObject { [listChanged] false } }, [serverInfo] new JsonObject { [name] demo-mcp-server, [version] 1.0.0 } }; break; case tools/list: response[result] new JsonObject { [tools] new JsonArray { new JsonObject { [name] get_current_time, [description] 获取当前UTC时间, [inputSchema] new JsonObject { [type] object, [properties] new JsonObject(), [required] new JsonArray() } } } }; break; case tools/call: var toolName request[params]?[name]?.GetValuestring(); if (toolName get_current_time) { response[result] new JsonObject { [content] new JsonArray { new JsonObject { [type] text, [text] DateTime.UtcNow.ToString(yyyy-MM-dd HH:mm:ss) } }, [isError] false }; } else { response[error] new JsonObject { [code] -32602, [message] $Unknown tool: {toolName} }; } break; default: response[error] new JsonObject { [code] -32601, [message] $Method not found: {method} }; break; } return Results.Json(response); });这段代码故意把三个核心方法都列了出来方便你对照协议理解。实际生产环境建议用一个McpServer类封装分发逻辑用Dictionarystring, FuncJsonObject, TaskJsonObject注册工具处理器避免把所有switch堆在端点里。有一点需要注意tools/call返回的内容不是普通字符串而是一个content数组里面每个元素可以标记为typetext或typeimage。ChatGPT拿到这个结果后会作为工具执行结果追加到对话里。如果你只关心文本从数组里取第一个text元素即可。2.3 快速验证初始化与工具列表服务端启动后先用curl做一次initialize握手确认协议版本和服务端能力curl -X POST http://localhost:5200/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2024-11-05, capabilities: {}, clientInfo: { name: demo-client, version: 0.1.0 } } }正常返回里应该有result.protocolVersion、result.capabilities和result.serverInfo。接着请求tools/listcurl -X POST http://localhost:5200/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:2,method:tools/list}返回的result.tools数组里就是那个get_current_time工具。这个验证步骤很重要它把MCP服务端本身先跑通后面接入ChatGPT时如果模型没正确调用工具我们就能立刻判断问题出在协议端还是模型端。3. 用C#写MCP客户端握手、列工具、调工具3.1 为什么建议先手写协议而不是一上来就引SDK市面上已经有了封装好的MCP客户端SDK直接引包确实能省不少工作量。但我的实际经验是如果一上来就躲在SDK后面遇到“tools/list返回了但工具不执行”这种问题你根本不知道是序列化、协议版本还是请求格式的问题。手写一个最小客户端只需要几十行核心代码却能让你把JSON-RPC的每个字段记得清清楚楚。以下客户端的思路仅依赖HttpClient和System.Text.Json没有额外NuGet包。它足够完整能够把MCP工具列表传出去也能调用tools/call。3.2 初始化握手与协议版本协商初始化是MCP连接的第一步。客户端要发一个initialize请求服务端会回复它支持的protocolVersion。这里有一个特别关键的细节客户端发送的版本是它期望的版本服务端可以选择返回一个它实际支持的版本。如果你发现返回版本和自己不一致优先选择服务端返回的较低版本继续工作而不是直接报错。public class McpClient { private readonly HttpClient _http; private readonly string _endpoint; private readonly JsonSerializerOptions _jsonOptions; private bool _initialized; public McpClient(HttpClient httpClient, string endpoint) { _http httpClient; _endpoint endpoint; _jsonOptions new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase }; } public async Task InitializeAsync(CancellationToken ct default) { var request new JsonObject { [jsonrpc] 2.0, [id] 1, [method] initialize, [params] new JsonObject { [protocolVersion] 2024-11-05, [capabilities] new JsonObject(), [clientInfo] new JsonObject { [name] demo-csharp-client, [version] 0.1.0 } } }; var response await SendRequestAsync(request, ct); var serverProtocol response[result]?[protocolVersion]?.GetValuestring(); if (string.IsNullOrEmpty(serverProtocol)) { throw new InvalidOperationException(初始化失败服务端未返回协议版本); } // 生产环境应在这里维护客户端支持版本列表并按需降级重试 _initialized true; } }握手完成后规范上还要发送一个notifications/initialized通知。HTTP场景下有些服务端会放宽这个要求但为了稳妥最好在InitializeAsync末尾补发一条没有id的通知var notification new JsonObject { [jsonrpc] 2.0, [method] notifications/initialized }; await SendRequestAsync(notification, ct);注意通知没有id服务端也不会返回结果。SendRequestAsync里要区分这两种情况有id的请求等待响应没id的通知直接发送即可。3.3 工具列表和工具调用的请求封装工具发现很简单请求tools/list把返回结果里的result.tools转换成强类型数据结构。我习惯定义一个ToolDefinitionpublic record ToolDefinition( string Name, string Description, JsonObject InputSchema);客户端方法public async TaskIReadOnlyListToolDefinition ListToolsAsync(CancellationToken ct default) { var request new JsonObject { [jsonrpc] 2.0, [id] 2, [method] tools/list }; var response await SendRequestAsync(request, ct); var tools new ListToolDefinition(); foreach (var toolNode in response[result]?[tools]?.AsArray() ?? new JsonArray()) { var obj toolNode!.AsObject(); tools.Add(new ToolDefinition( obj[name]?.GetValuestring() ?? string.Empty, obj[description]?.GetValuestring() ?? string.Empty, obj[inputSchema]?.AsObject() ?? new JsonObject())); } return tools; }调用工具时需要把参数完整放进params.arguments。由于ChatGPT返回的arguments本身是JSON字符串你在客户端这里通常可以直接传给MCP Server但要注意反序列化成JsonObject再发送避免出现字符串嵌套字符串。public async Taskstring CallToolAsync( string toolName, JsonObject arguments, CancellationToken ct default) { var request new JsonObject { [jsonrpc] 2.0, [id] 3, [method] tools/call, [params] new JsonObject { [name] toolName, [arguments] arguments } }; var response await SendRequestAsync(request, ct); if (response.ContainsKey(error)) { var error response[error]!.AsObject(); throw new McpToolException( error[code]?.GetValueint() ?? 0, error[message]?.GetValuestring() ?? unknown); } var content response[result]?[content]?.AsArray(); var textParts content? .Where(n n?[type]?.GetValuestring() text) .Select(n n![text]?.GetValuestring()) .Where(s !string.IsNullOrEmpty(s)) .ToList(); return textParts is { Count: 0 } ? string.Join(\n, textParts) : string.Empty; }到这里C#已经能作为一个标准MCP客户端工作了。接下来的关键是如何让ChatGPT知道这些工具存在并在对话过程中自动触发调用。4. 让ChatGPT学会“叫人干活”工具调用与MCP联动4.1 把MCP工具描述翻译成ChatGPT的工具格式ChatGPT接口里描述工具的方式是tools数组每个工具包含type、function字段而function里又有name、description、parameters。对照一下MCP的tools/list输出你会发现结构非常接近MCP的name对应function.nameMCP的inputSchema就是function.parameters。唯一要补的是type必须固定为function。MCP 工具字段ChatGPT tools 字段说明namefunction.name模型用来生成调用指令的唯一标识descriptionfunction.description模型判断何时调用工具的关键提示inputSchemafunction.parameters参数JSON Schema描述参数名、类型、是否必填—type固定为function表示这是一个可调用函数转换代码非常直接var chatTools mcpTools.Select(t new JsonObject { [type] function, [function] new JsonObject { [name] t.Name, [description] t.Description, [parameters] t.InputSchema } }).ToArray();这里最容易翻车的地方是parameters。MCP Server返回的inputSchema可能没有顶层$schema字段但这不影响使用而ChatGPT要求parameters必须是合法的JSON Schema对象至少包含type和properties。如果你某个工具没有定义参数请确保inputSchema至少是{type:object,properties:{}}不要让这个字段变成null。4.2 对话循环从聊天请求到工具执行再回到聊天让模型主动调用工具本质是个多轮对话循环把用户输入加入messages请求ChatGPT同时附上tools。如果模型返回tool_calls说明它决定调用工具如果没有直接输出最终回复。对每个tool_call取出function.name和function.arguments调用MCP客户端的CallToolAsync。把模型那条包含tool_calls的消息加入messages再把每个工具执行结果作为role: tool的消息加入messagestool_call_id必须和模型返回的一致。带着更新后的messages再请求ChatGPT得到最终回答或者模型再次决定调用其他工具。这个过程要用循环包住不能只跑一次。多个工具之间可能有依赖比如先查城市代码再查天气。为了避免死循环循环里要设置最大轮数我一般控制在5轮以内。还有一个ChatGPT消息格式问题当你把工具结果追加进messages后序列化时不能把content写成对象要写成字符串。工具结果如果是多行文本直接作为字符串塞进content字段。模型能看到工具返回的完整文本但看不到MCP内部那些content数组的包装结构。4.3 一个可以跑通的最小示例下面是实际控制台程序的主流程骨架我简化了错误处理突出核心脉络var httpClient new HttpClient { Timeout TimeSpan.FromSeconds(60) }; var mcpClient new McpClient(httpClient, http://localhost:5200/mcp); await mcpClient.InitializeAsync(); var mcpTools await mcpClient.ListToolsAsync(); var chatTools mcpTools.Select(...).ToArray(); var messages new ListJsonObject(); messages.Add(new JsonObject { [role] system, [content] 你是一个桌面助手必要时可以调用工具获取最新信息。 }); while (true) { Console.Write(你); var input Console.ReadLine(); if (string.IsNullOrWhiteSpace(input)) continue; messages.Add(new JsonObject { [role] user, [content] input }); var round 0; while (round 5) { round; var chatResponse await chatClient.GetCompletionAsync(messages, chatTools); var assistantMessage ExtractAssistantMessage(chatResponse); messages.Add(assistantMessage); if (!assistantMessage.ContainsKey(tool_calls)) break; foreach (var toolCall in assistantMessage[tool_calls]!.AsArray()) { var functionNode toolCall![function]!.AsObject(); var toolName functionNode[name]!.GetValuestring(); var argumentsText functionNode[arguments]!.GetValuestring(); var arguments JsonNode.Parse(argumentsText)?.AsObject() ?? new JsonObject(); var resultText await mcpClient.CallToolAsync(toolName, arguments); messages.Add(new JsonObject { [role] tool, [tool_call_id] toolCall[id]!.GetValuestring(), [content] resultText }); } } }这里的chatClient是对ChatGPT接口的简单封装核心是构造POST /v1/chat/completions请求体带上model、messages、tools然后解析返回的choices[0].message。ExtractAssistantMessage函数把返回的assistant消息转成一个JsonObject注意保留原始的tool_calls结构。我用这个结构做过本地时间查询、文件列表读取、简单计算器几个工具整体跑得很顺。模型确实会在用户问“现在几点”时调用get_current_time拿到结果后再组织自然语言返回。这种“模型负责思考工具负责执行”的模式比直接解析关键词可靠得多。5. 从Demo到落地调试经验、安全问题和进阶玩法5.1 我踩过的几个坑这套链路虽然有文档实际调试时还是会遇到不少隐蔽问题。我把常见的几个整理成一张表方便你对号入座现象根因解决MCP服务端始终报Method not found大小写无法匹配确保请求字段使用camelCase序列化时配置JsonNamingPolicy.CamelCase握手后调用工具提示“未初始化”缺少notifications/initialized通知在initialize后补发一次notifications/initializedChatGPT返回的arguments解析失败arguments是字符串不是对象先用JsonNode.Parse解析字符串再发送工具执行成功但模型说“没有可用信息”工具结果没有正确作为tool消息回传确保每个tool_call后都有对应tool_call_id的tool消息运行久后请求超时HttpClient默认超时100秒工具执行慢给MCP调用单独设置更长超时给ChatGPT调用单独设置合理超时模型反复调用同一个工具工具描述不清晰或参数要求不合理优化description说明何时该调用、何时不该调用最让我头疼的是函数参数大小写问题。MCP协议字段全部是camelCase而我习惯的C#类属性是PascalCase。一开始用JsonSerializer.Serialize(new { Method tools/list })默认输出Method服务端自然不认。现在统一在JsonSerializerOptions里配置PropertyNamingPolicy JsonNamingPolicy.CamelCase才彻底解决。另一个容易踩的是协议版本。MCP版本在持续演进老服务端返回的protocolVersion可能比客户端发的小。如果你不支持降级就直接中断。稳妥做法是客户端维护一个兼容版本列表服务端返回哪个版本客户端就按哪个版本后续通信。5.2 API Key、权限与工具白名单接上ChatGPT之后安全责任会比纯聊天应用重很多。因为模型现在有能力触发工具而工具可能涉及文件读取、服务调用、甚至写数据库。第一绝不能把API Key直接写进客户端配置或前端代码。正确做法是把Key放在服务端环境变量或密钥管理服务里由你的后端统一请求ChatGPT接口MCP Server端如果部署在独立进程中同样不要继承不必要的密钥。第二要给危险工具设置明确的权限边界。MCP Client拿到tools/list返回的只是工具描述并不代表这些工具都可以被AI随意调用。我建议在C#宿主层维护一张工具白名单只有白名单内的工具能被映射给ChatGPT。比如get_current_time可以放行delete_file必须拦下来并提示人工确认。第三要对模型生成的arguments做基本校验。不要直接透传到核心系统。哪怕MCP Server端已经做了参数校验你也应该有一套基础的保险比如限制工具执行时长记录完整的调用日志出现异常时能回放是哪一轮对话触发的。这里我额外提醒一句MCP工具列表本身就是提示词注入的潜在入口。如果某个工具的描述文本被恶意替换模型可能被引导执行非预期调用。稳妥做法是把工具描述当成不可信输入对待不要直接拼进系统提示词。5.3 后续可以怎么扩展最小链路跑通以后扩展方向很多。首先是传输层如果你要把本机Python脚本或其他命令行进程作为工具接入可以使用stdio传输C#通过Process启动子进程然后写stdin、读stdout。这样不需要开HTTP端口进程生命周期由宿主管理更安全。其次是使用现成的官方MCP SDK来替代手写协议。踩了一遍坑之后再去封装你就能看懂SDK里为什么要做重试、为什么要维护请求id映射表。SDK能帮你处理分页、警告、能力协商这些边角料但核心调用链路的理解还是来自这次手写实践。再一个是ChatGPT侧的流式输出。当前示例是非流式的用户等完整回复生成完才能看到文字。如果你的应用面向交互频繁的桌面端建议把stream: true打开然后按SSE事件拼接内容。这样体验会好很多。MCP不只是工具调用它还有资源resources和提示模板prompts。资源适合把内部文档、配置信息暴露给模型提示模板适合把一些固定指令组织成可复用单元。等你把工具调用吃透可以继续扩展这两部分。如果以后工具数量膨胀到几十个建议在C#宿主层加一个Router把不同MCP Server暴露的工具做命名空间前缀。比如weather__get_current、time__get_current这样模型可用理解更清晰也方便你按服务分组审计。最后分享一点我自己的体会。MCP给C#带来的最大价值不是省几行代码而是把“模型要调用什么”和“程序能提供什么”彻底分开。一开始手写协议时你会觉得麻烦但工具超过5个边界就会变清晰。等你在生产环境踩过几轮就会明白版本协商、工具Schema和权限控制得在第一天就设计好。这套组合目前在我的某桌面助手项目里已经跑得很稳。如果你的C#项目也准备接ChatGPT做工具调用建议先照这篇文章把最小链路跑通再慢慢加工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑