最近很多做 .NET 的朋友开始关注同一个问题:能不能用 C# 写一套同时具备 MCP 和 ChatGPT 能力的应用?我的答案是可以,而且实际做下来比想象中简单。MCP 是当下把大模型和业务系统连接起来的主流协议,ChatGPT 这类对话能力大家也很熟悉,把它们放在同一条技术链路里的最大价值,是让模型不再只是“聊天”,而是能真正操作你的工具、读取你的数据、完成实际任务。这篇文章会从设计思路、项目搭建、服务端实现、对话接入、工程化细节和常见坑点几个部分展开,适合已经会写 C#、想快速上手的开发者。
1. 内容整体设计与思路拆解
1.1 MCP 到底在解决什么问题
先用一个生活化的类比。以前每个 AI 应用想接入外部系统,都要写一套专门代码:接天气接口写一段,接数据库写一段,接内部工单系统又写一段。这就像每个设备都有自己的充电口,插座五花八门。MCP 做的事情,是把这些能力统一成一个标准插头:服务提供方按同一套协议暴露能力,模型和应用按同一套协议调用能力。
MCP 是一种基于 JSON-RPC 的开放协议。它的通信双方是客户端和服务器,中间还有宿主应用。服务器负责把能力暴露出来,客户端负责建立连接、发送请求,宿主负责把用户界面和模型结合起来。在一条典型请求里,用户问“订单 123456 现在到哪了”,模型不需要直接知道数据库怎么连,它只需要知道有一个叫query_order的工具,然后把参数orderId=123456传进去,最后把工具返回的结果整理成用户能听懂的话。
这种设计最大的好处是解耦。工具提供方不需要关心模型是什么,模型也不需要关心工具背后的实现细节。今天你用某个大模型,将来换成另一个,只要 MCP 协议的接口不变,业务代码基本不用动。
1.2 MCP 的三个核心原语:Tools、Resources、Prompts
MCP 协议里有三个经常出现的关键词:Tools、Resources、Prompts。很多新手一上来就混淆,这里整理成一张表:
| 原语 | 作用 | 生活类比 | 对应协议方法 |
|---|---|---|---|
| Tools | 可执行的动作,比如查询订单、发送通知 | 按钮和开关 | tools/list、tools/call |
| Resources | 可读取的数据,比如设备状态、文档内容 | 抽屉和文件 | resources/list、resources/read |
| Prompts | 可复用的提示模板,比如固定的角色设定 | 快捷小抄 | prompts/list、prompts/get |
在这篇文章实现的应用里,我会重点用tools这一类。原因很简单:智能助手最核心的能力是可以执行操作。跟静态读取数据相比,工具调用有参数、有返回值、有错误处理,最能体现 MCP 的价值。
1.3 为什么用 C# 做 MCP/ChatGPT 应用
很多人一听到 MCP,第一反应是 Node.js 或者 Python。但实际上,C# 做这条路不仅可行,在特定场景下还更合适。
第一个原因是存量系统复用。很多企业内部已经有大量 .NET 服务,订单系统、库存系统、通知系统都是 C# 写的。让 MCP 服务器直接跑在这些系统旁边,甚至直接嵌入现有服务进程,比用另一种语言重写一遍要省事得多。
第二个原因是类型系统。C# 的强类型在处理 JSON Schema 时非常舒服。MCP 要求每个工具都对外暴露一个inputSchema,用 C# 的匿名对象或者 record 可以直接构建结构清晰的 Schema。配合 System.Text.Json,序列化和反序列化几乎不用额外操心。
第三个原因是异步与并发。MCP 服务器需要同时处理多个客户端的请求,而且很可能要处理 SSE 长连接。C# 的 async/await 在 IO 密集型场景下表现稳定,配合 ASP.NET Core 自带的依赖注入、日志、配置体系,工程化能力比手写脚本强很多。
2. 项目骨架搭建与依赖管理
2.1 解决方案与项目目录设计
建议不要只创建一个控制台项目,而是按职责拆成多层。一个小型智能助手 Demo 至少可以拆成四个项目:
- 一个 Host,负责进程入口、配置和组装依赖
- 一个 Core,放订单、库存、通知等业务逻辑接口
- 一个 Mcp,放 MCP 协议消息模型和处理逻辑
- 一个 WebHost,用 ASP.NET Core 暴露 HTTP 端点
先用命令行创建解决方案:
dotnet new sln -n SmartToolDemo dotnet new console -n SmartToolDemo.Host dotnet new classlib -n SmartToolDemo.Core dotnet new classlib -n SmartToolDemo.Mcp dotnet new web -n SmartToolDemo.WebHost dotnet sln add SmartToolDemo.Host SmartToolDemo.Core SmartToolDemo.Mcp SmartToolDemo.WebHost拆分的逻辑很清楚:Core 层不含任何协议相关内容,方便单独测试业务逻辑;Mcp 层只负责协议,不关心业务实现;Host 和 WebHost 是入口。这样后续把 MCP 服务器的传输层从 HTTP 换成 stdio,或者增加新的传输方式,都不会影响业务代码。
2.2 协议实现方式选型:手写还是用现成 SDK
关于 MCP 实现,有两条路线可选。
第一条是使用现成的 MCP SDK。这类 SDK 会帮你处理握手、消息序列化、工具注册、SSE 推送等细节,开发效率确实高。缺点是引入了一个抽象层,一旦网络请求异常、序列化结果跟预期不一致,排查起来会像在拆黑盒。
第二条是手写协议层。MCP 本身不复杂,核心是 JSON-RPC 2.0 消息的收发。自己实现一遍,最大的收获是可以彻底理解协议细节。这篇博文我选择用手写的方式带你过一遍,等跑通之后再切换成生产可用的 SDK 也不迟。
2.3 传输方式选择:HTTP、SSE 与 stdio
MCP 支持多种传输方式,最常见的有两种:本地进程用 stdio,远程服务用 HTTP + SSE。
stdio 模式适合本地命令行工具,宿主和服务器在同一台机器上,通过标准输入输出来回传消息。这种模式部署简单,但很难做远程调用。
HTTP + SSE 模式更适合我们的业务场景。客户端通过 POST 发送 JSON-RPC 请求,服务器通过 SSE 长连接推送通知和事件。我们在 WebHost 项目里用 ASP.NET Core Minimal API 暴露一个/mcp端点,所有消息都从这里收发。这个模式的好处是,客户端可以是任意语言,只要它能发 HTTP 请求。
3. 手写一个最简 MCP 服务器
3.1 定义 JSON-RPC 消息模型
MCP 的底层协议是 JSON-RPC 2.0。每一个请求基本长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }响应也遵循同样格式。我在Mcp项目里定义两个 record 用来表示请求和响应:
public record RpcRequest( string JsonRpc, string Id, string Method, JsonElement? Params); public record RpcResponse( string JsonRpc, string? Id, object? Result, int? ErrorCode, string? ErrorMessage);这里需要特别说明的是Id。请求和响应必须一一对应,异步场景下客户端会发多个请求,如果服务器不把Id原样返回,客户端就不知道这个响应属于哪一次调用。
3.2 客户端连上来先做 initialize 握手
MCP 客户端连上服务器后,第一件事是发送initialize请求。这个请求的作用是协商协议版本和确认双方能力。服务器返回的内容至少包括:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "smart-tool-demo", "version": "1.0.0" } } }protocolVersion这个字段非常关键。MCP 迭代速度很快,如果客户端请求的版本和服务器版本不兼容,有的客户端会直接拒绝连接。因此在服务端实现时,最好维护一个兼容版本列表,遇到不认识的版本时返回一个明确错误,而不是直接崩溃。
3.3 实现 tools/list 和 tools/call
工具目录用列表返回,每个工具包含三部分:name、description、inputSchema。我先定义三个工具:查订单、查库存、发通知。
var tools = new[] { new { name = "query_order", description = "根据订单号查询订单最新状态", inputSchema = new { type = "object", properties = new { orderId = new { type = "string", description = "订单编号,例如 ORD20250101" } }, required = new[] { "orderId" } } }, new { name = "query_inventory", description = "根据SKU查询当前库存数量", inputSchema = new { type = "object", properties = new { sku = new { type = "string", description = "商品SKU编码" } }, required = new[] { "sku" } } } };很多新手会在description上偷懒,只写一句话。但实际上,这段描述是模型决定“要不要调用”以及“传什么参数”的重要依据。描述写得越准确,工具被调用的概率越高。
tools/call的请求里会带上工具名和参数,服务器收到后做参数校验和业务分发:
switch (toolName) { case "query_order": var orderId = arguments.GetProperty("orderId").GetString(); result = await orderService.QueryOrderAsync(orderId); break; case "query_inventory": var sku = arguments.GetProperty("sku").GetString(); result = await inventoryService.QueryAsync(sku); break; default: return RpcResponse.Error(-32601, $"工具 {toolName} 不存在"); }工具执行成功后,返回内容需要包成 MCP 标准格式:
{ "content": [ { "type": "text", "text": "订单 ORD20250101 当前状态:已发货" } ] }为什么不能直接返回一串字符串?因为 MCP 的content数组支持多种内容类型,包括文本、图片、资源链接。模型看到这个结构,才知道如何把工具结果展示给用户。
3.4 用 ASP.NET Core 暴露 MCP 端点
在 WebHost 里创建 Minimal API 的入口:
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapMethods("/mcp", new[] { "POST" }, async (HttpContext context) => { using var reader = new StreamReader(context.Request.Body); var body = await reader.ReadToEndAsync(); var rpcRequest = JsonSerializer.Deserialize<RpcRequest>(body); var response = await McpDispatcher.DispatchAsync(rpcRequest); await context.Response.WriteAsJsonAsync(response); }); app.Run();这里要注意的是,MCP 客户端通常会传Content-Type: application/json,但有些会带application/json; charset=utf-8。ASP.NET Core 默认可以处理,但如果你要手动解析,注意 charset 参数的存在。
3.5 用 curl 做一次联调
启动 WebHost 后,先测试initialize:
curl -X POST http://localhost:5100/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'再测试tools/list:
curl -X POST http://localhost:5100/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'看到返回的工具列表,就说明你的 MCP 服务器核心链路已经通了。这个验证过程非常重要,我建议你手工跑一遍,熟悉这两个请求,后面排查问题会轻松很多。
4. 接入 ChatGPT 风格的对话能力
4.1 封装一个可替换的对话模型客户端
要做 ChatGPT 应用,就得有一个和对话模型交互的客户端。我不建议直接把代码耦合到某个厂商的 SDK,更稳妥的做法是定义一个自己的接口,方便后续替换:
public interface IDialogClient { Task<string> CompleteAsync(IReadOnlyList<ChatMessage> messages); }ChatMessage至少包含角色和内容两部分:
public record ChatMessage(string Role, string Content);在具体实现里,我用HttpClient调用对话模型提供的 HTTP 接口。这样做的好处是,不管底层是哪个厂商的模型,只要支持相同的 HTTP 接口形态,我就能无缝切换。
public class HttpDialogClient : IDialogClient { private readonly HttpClient _httpClient; private readonly DialogSettings _settings; public async Task<string> CompleteAsync(IReadOnlyList<ChatMessage> messages) { var payload = new { model = _settings.Model, messages = messages.Select(m => new { role = m.Role, content = m.Content }) }; using var request = new HttpRequestMessage(HttpMethod.Post, _settings.ApiBase); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _settings.ApiKey); request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); var response = await _httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); return ExtractContentFromResponse(json); } }生产环境里,真实模型的响应格式可能更复杂,还会有用量统计等字段,但核心逻辑就是这个:把消息历史发给模型,拿到生成结果。
4.2 把 MCP 工具描述注入模型提示词
现在关键问题来了:模型怎么知道有哪些工具可以用?最简单直接的方式,是在system消息里把工具列表变成文本描述。
你是一个智能客服助手。你可以使用以下工具: 1. query_order:根据订单号查询订单最新状态,参数:orderId(订单编号) 2. query_inventory:根据SKU查询库存数量,参数:sku(商品SKU编码) 当用户请求查询信息时,你必须严格输出以下JSON格式: {"tool": "工具名", "arguments": { "参数名": "参数值" } } 如果用户没有明确指定参数,你需要先向用户询问。这样的提示词不用改代码,就能让模型“看到”工具。更规范的做法是用对话服务原生的工具调用能力,把 MCP 里的inputSchema直接映射成模型 API 里的函数定义。如果条件允许,我推荐后者,因为模型的工具选择会更稳定。
但无论哪种方式,核心思想是一样的:MCP 负责定义工具,应用负责把工具信息翻译给模型。
4.3 执行工具并把结果回填给模型
当模型返回了一段工具调用意图后,需要先解析它:
private sealed class ToolIntent { public string Tool { get; set; } = ""; public Dictionary<string, object> Arguments { get; set; } = new(); }在实际项目中,你会发现模型生成的 JSON 经常不干净。比如会在结果外层包一个 markdown 代码块:
```json {"tool": "query_order", "arguments": {"orderId": "ORD20250101"}} ```所以解析前要做好清理:
var text = reply.Trim(); if (text.StartsWith("```")) { text = text.Replace("```json", "") .Replace("```", "") .Trim(); } var intent = JsonSerializer.Deserialize<ToolIntent>(text);拿到工具意图后,调用 MCP 的执行入口,然后把结果追加到对话历史上:
var toolResult = await mcpExecutor.CallAsync(intent.Tool, intent.Arguments); messages.Add(new ChatMessage("assistant", $"工具调用结果:{toolResult}")); var finalReply = await dialogClient.CompleteAsync(messages);为什么要把工具结果放到消息历史里再问一次?因为模型第一次生成回复时并不知道工具执行的真实结果。它只知道“我决定调用这个工具”,只有把结果喂回去,它才能基于真实数据给出最终答复。这个机制很多新手容易漏:工具返回后直接返回给用户,模型的语气和总结能力就浪费了。
4.4 流式输出的体验优化
如果是一次性返回所有文字,用户会感觉交互迟钝。对话应用通常需要流式输出。使用HttpClient时,只要在服务端支持的情况下,可以开启流式读取:
using var request = new HttpRequestMessage(HttpMethod.Post, _settings.ApiBase); // 设置请求头和内容 using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); await using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (string.IsNullOrEmpty(line)) continue; // 如果服务端返回的是 SSE 格式,需要跳过事件名前缀 var data = line.StartsWith("data:") ? line.Substring("data:".Length).Trim() : line; await Console.Out.WriteLineAsync(data); }流式输出会引入一个新的复杂度:你没法在拿到完整文本后再判断模型是否想调用工具。通常的做法是,先关闭流式,让模型决定是否要调用工具;如果模型调用了工具,就把工具结果再次发送给模型,这次再开启流式输出。简单说,只有最后一段回复才适合流式。
5. 工程化打磨:从 Demo 到可用服务
5.1 配置管理与密钥保护
绝不能把续期密钥硬编码到代码里。密钥泄露一次就够你后悔的。ASP.NET Core 有现成的配置体系,可以用appsettings.json:
{ "Dialog": { "ApiBase": "https://api.example.com/v1/chat/completions", "ApiKey": "", "Model": "smart-chat-v1" }, "Mcp": { "Endpoint": "/mcp" } }开发环境下,建议使用开发机上的用户密钥管理功能;生产环境最好用专门的密钥托管服务,让应用从环境变量或者密钥管理平台读取。写代码时用配置绑定,不要自己写一堆Configuration.GetValue的散弹枪代码。
var dialogSettings = builder.Configuration .GetSection("Dialog") .Get<DialogSettings>();5.2 日志、超时与错误码
MCP 服务器在真实场景中要同时服务多个客户端,如果没有日志和超时控制,线上出了问题会非常难查。我建议在/mcp端点外层加一个简单的日志中间件,记录请求方法、耗时和响应状态。
错误码的规范要提前定好。JSON-RPC 2.0 本身已经定义了一些标准错误码:
| 错误码 | 含义 | 典型场景 |
|---|---|---|
| -32700 | 解析错误 | 请求体不是合法 JSON |
| -32600 | 无效请求 | 缺少 jsonrpc 字段 |
| -32601 | 方法不存在 | 调用未知的 MCP 方法 |
| -32602 | 参数无效 | 缺少必填参数 |
业务工具内部的异常,比如数据库连接失败,不要直接抛给客户端。应该捕获之后,返回一个结构化的错误信息,同时把isError标记置为 true。客户端可以根据这个标记决定是重试还是放弃。
5.3 自动化测试策略
手写协议层最大的好处就是测试容易。工具逻辑放在 Core 层,可以用普通单元测试覆盖;协议分发逻辑放在 Mcp 层,可以用内存里的模拟业务服务做测试;整体联调用WebApplicationFactory把 WebHost 跑起来,再发送真实的 MCP 协议消息。
var client = factory.CreateClient(); var payload = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list"",""params"":{}}"; var response = await client.PostAsync("/mcp", new StringContent(payload, Encoding.UTF8, "application/json")); response.EnsureSuccessStatusCode();端到端测试里,我不会真的调用真实对话模型,而是用一个回传固定 JSON 意图的模拟客户端。这样才能保证测试不受网络、模型行为波动的影响。
6. 常见问题与排查技巧实录
6.1 工具已经注册,但模型“看不见”工具
这是新手最容易卡的地方。模型看不到工具,问题往往出在tools/list返回的格式上。先用curl手动请求tools/list,看返回里工具的inputSchema是否是合法的 JSON Schema。
还有一个常见原因是,initialize阶段返回的capabilities没有包含tools。某些客户端只有从initialize结果里看到capabilities.tools,才会真的去请求tools/list。
排查顺序建议是:先看initialize返回,再看tools/list返回,最后看模型提示词里是否真的包含工具描述。
6.2 对话模型返回的不是标准 JSON
当模型是使用提示词方式感知工具时,它给出的“工具意图”经常会被 markdown 代码块包裹,或者夹杂其他解释性文字。所以解析前一定要做清洗,至少做两步:去掉首尾空白、去掉代码块标记。更稳妥的方案是使用原生函数调用能力,让模型以结构化方式返回,而不是自己编 JSON。
6.3 SSE 推送总是中断
如果你用 HTTP + SSE 做服务端主动推送,部署到正式环境时经常发现连接一段时间后自动断开。这种问题大部分是网关或代理服务器的空闲超时造成的。搭建时要注意确认反向代理是否开启了缓冲,有的网关注定不会立刻转发 SSE 内容,而是攒够一定量才发送,这样实时性完全没了。解决方向是配置代理不缓存事件流,同时保持心跳机制,定期发送注释行避免连接空闲。
6.4 System.Text.Json 序列化细节
MCP 工具参数里的required字段,在 JSON Schema 里是一个字符串数组。如果用 C# 匿名类型构建,很容易忘记转换成数组。另外,C# 默认的序列化是区分大小写的,而不同 MCP 客户端的字段命名可能不一样。统一使用小驼峰命名,并在反序列化时打开大小写不敏感选项。
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };这几处细节虽然小,但能减少很大一部分联调摩擦。
最后分享一点个人经验。我刚开始做 MCP 的时候,总想着把协议 SDK、工具注册中心、权限校验全套铺开,结果被各种概念绕晕。后来我给自己的要求很简单:先把initialize、tools/list、tools/call这三个方法跑通,再谈其他。一旦你把这三板斧守住了,后续加多少工具、接多少个模型,都是在这个稳定基座上做增量。MCP 不是魔法,它只是一套让工具和模型对话的规则。规则本身不难,难的是你愿意静下心来把消息格式、错误码、超时这些看似琐碎的细节处理好。把地基打稳,应用自然立得住。