基于.NET 8构建MCP服务器:为AI助手打造安全的外部工具集成
1. 项目概述与核心价值最近在折腾AI应用开发特别是想给自家的聊天机器人加点“超能力”让它能直接读取我电脑里的文件、查查数据库甚至控制一下智能家居。这听起来像是要写一大堆复杂的插件和集成代码对吧一开始我也这么觉得直到我深入研究了mehrandvd/tutorial-mcp-server-dotnet这个项目。简单来说这是一个用 .NET 8 构建模型上下文协议Model Context Protocol MCP服务器的实战教程。MCP 是 Anthropic 提出的一种开放协议旨在让 AI 助手比如 Claude能够安全、标准化地访问外部工具和数据源而无需为每个助手单独开发集成。这个教程的价值在于它没有停留在概念层面而是手把手带你从零开始用 C# 和 .NET 8 构建一个功能完整的 MCP 服务器。无论你是 .NET 后端开发者想为你的 AI 应用添加智能体能力还是对 AI 与现有系统集成感兴趣的探索者这个项目都提供了一个绝佳的切入点。它解决的正是“如何让 AI 安全、可控地使用我们已有的系统和数据”这个核心痛点。通过完成这个教程你不仅能掌握 MCP 协议的核心机制更能获得一套可复用的 .NET 项目模板快速为你自己的业务系统打造专属的 AI “手和脚”。2. MCP 协议核心机制与 .NET 实现选型在动手写代码之前我们必须先搞清楚 MCP 到底是什么以及为什么选择 .NET 8 作为实现平台。这决定了我们整个项目的架构设计思路。2.1 MCP 协议AI 的“标准化插座”你可以把 MCP 想象成给 AI 模型用的“USB 接口”或“标准化插座”。在没有 MCP 之前每个 AI 应用如 Claude Desktop、Cursor如果想连接外部资源如文件系统、数据库、API都需要开发者为其定制开发专用的“插头”和“电线”工作重复且难以维护。MCP 定义了一套标准的“插座”规格即协议包括资源ResourcesAI 可以读取的“只读”数据源例如文件、数据库查询结果、API 返回的静态数据。工具ToolsAI 可以调用的“可执行”操作例如执行一个命令、写入一个文件、调用一个函数。提示Prompts预定义的、参数化的文本模板AI 可以获取并用于生成更符合上下文的回复。协议规定了服务器我们即将构建的与客户端如 Claude Desktop之间通过JSON-RPC over stdio标准输入输出或 SSE服务器发送事件进行通信。所有交互无论是列出可用工具还是调用某个工具并返回结果都遵循严格的 JSON 格式。这种设计使得客户端和服务器完全解耦一个 MCP 服务器可以同时为多个兼容 MCP 的客户端提供服务。2.2 为什么选择 .NET 8 进行实现mehrandvd/tutorial-mcp-server-dotnet项目选择 .NET 8 作为实现基础这是一个非常务实且高效的选择背后有几点关键考量强大的生态系统与生产力.NET 8 是微软最新的长期支持版本提供了出色的性能、跨平台支持和丰富的库生态系统。对于需要与文件系统、数据库、网络服务、本地进程打交道的 MCP 服务器来说.NET 的基础类库BCL和 NuGet 包生态能极大提升开发效率。例如使用System.IO处理文件、用System.Text.Json高效序列化协议数据、用Microsoft.Extensions系列包管理配置和依赖注入都是开箱即用的成熟方案。面向 AI 应用开发的现代特性.NET 8 在性能尤其是原生 AOT、容器化支持、云原生开发方面有显著增强。构建的 MCP 服务器可以轻松打包成轻量级 Docker 镜像部署在任何环境。此外.NET 社区对 AI 的支持日益增长与 ML.NET、Semantic Kernel 等框架的集成也为未来扩展提供了可能。类型安全与可维护性C# 是一门强类型语言结合 .NET 8 的现代语法如记录类型、顶级语句、模式匹配可以让我们构建出结构清晰、类型安全、易于测试和维护的协议实现。这对于处理具有复杂结构的 JSON-RPC 消息尤为重要能有效减少运行时错误。教程作者的匠心该项目作者提供了完整的、循序渐进的指南从项目初始化、协议基础类型定义到实现具体工具和资源最后完成集成测试。这对于 .NET 开发者来说学习曲线平缓能够快速上手并理解 MCP 的核心。注意虽然教程使用 .NET 8但 MCP 协议本身是语言无关的。理解其核心概念后你可以用 Python、Go、Rust 等任何语言实现。选择 .NET 主要是基于现有技术栈和开发效率的权衡。3. 项目结构与环境搭建实战接下来我们进入实战环节跟随教程一步步搭建开发环境并初始化项目。我会补充一些教程中可能未详述的细节和避坑点。3.1 开发环境与工具链准备首先确保你的开发机器上已经安装了以下软件.NET 8 SDK这是核心。可以从微软官网或使用命令行工具安装。安装后在终端运行dotnet --version确认版本为 8.0.x 或更高。代码编辑器强烈推荐Visual Studio 2022社区版免费或Visual Studio Code。VS 提供了对 .NET 项目最完整的支持而 VS Code 搭配 C# 扩展同样非常强大且更轻量。Git用于克隆教程仓库和管理你的代码版本。一个 MCP 客户端用于测试。最常用的是Claude Desktop。你需要在其设置中配置 MCP 服务器。另一种选择是使用MCP 客户端模拟器或编写简单的测试脚本教程后期会涉及。3.2 初始化项目与核心依赖解析教程通常从创建一个新的控制台应用程序开始。我们打开终端执行以下命令# 创建一个新的控制台应用项目 dotnet new console -n MyFirstMcpServer -f net8.0 cd MyFirstMcpServer接下来我们需要添加实现 MCP 协议所需的核心 NuGet 包。虽然你可以完全从零实现 JSON-RPC 的序列化和通信但使用社区库能事半功倍。教程可能会推荐或使用特定的辅助库。一个常见的选择是StreamJsonRpc或JsonRpc.Net来处理 JSON-RPC 通信层但为了更贴近 MCP 语义我们更关注定义协议模型。实际上更直接的方法是参考 Anthropic 官方提供的TypeScript/JavaScript SDK的类型定义在 C# 中创建对应的模型类Record。不过社区已经有一些先行者。我们可以手动创建这些模型这本身也是一个学习过程。首先添加对System.Text.Json的显式引用虽然默认包含并确保其版本合适。然后我们创建项目核心结构MyFirstMcpServer/ ├── Models/ │ ├── McpMessage.cs # JSON-RPC 基础消息模型 │ ├── Request.cs # 请求模型如 initialize, tools/list │ ├── Response.cs # 响应模型 │ ├── Notification.cs # 通知模型 │ └── Types/ # 协议中使用的各种类型定义 │ ├── Tool.cs │ ├── Resource.cs │ ├── Prompt.cs │ └── ... ├── Services/ │ ├── IMcpServer.cs # 服务器接口 │ └── McpServer.cs # 服务器核心实现处理 stdio 通信 ├── Tools/ # 具体工具的实现 │ └── FileReadTool.cs ├── Resources/ # 具体资源的实现 │ └── ... ├── Program.cs # 应用程序入口 └── MyFirstMcpServer.csproj在Models/Types/Tool.cs中我们定义工具的描述结构这需要严格遵循 MCP 协议规范// Models/Types/Tool.cs using System.Text.Json.Serialization; namespace MyFirstMcpServer.Models.Types; public record Tool( [property: JsonPropertyName(name)] string Name, [property: JsonPropertyName(description)] string Description, [property: JsonPropertyName(inputSchema)] InputSchema InputSchema ); public record InputSchema( [property: JsonPropertyName(type)] string Type, // 固定为 object [property: JsonPropertyName(properties)] Dictionarystring, PropertySchema Properties, [property: JsonPropertyName(required)] Liststring? Required null ); public record PropertySchema( [property: JsonPropertyName(type)] string Type, // string, number, integer, boolean [property: JsonPropertyName(description)] string? Description null, [property: JsonPropertyName(enum)] Liststring? Enum null );实操心得使用 C# 9.0 引入的record类型来定义这些模型是绝佳选择。record默认提供值语义的相等比较并且与System.Text.Json的序列化/反序列化配合得天衣无缝。务必使用[JsonPropertyName]特性来确保 JSON 属性名与 MCP 协议规范完全一致这是跨语言通信不出错的关键。4. 核心通信层与服务器主循环实现MCP 服务器本质上是一个遵循 JSON-RPC 规范、通过标准输入输出stdio与客户端通信的进程。实现一个健壮的主循环是项目的核心。4.1 标准输入输出Stdio通信模式MCP 最常见的传输方式是 stdio。这意味着我们的控制台应用将从Console.In标准输入读取客户端发送的 JSON-RPC 请求并将处理后的响应写入Console.Out标准输出。这种模式简单、通用几乎被所有 MCP 客户端支持。在Services/McpServer.cs中我们需要实现一个持续运行的循环// Services/McpServer.cs using System.Text.Json; using MyFirstMcpServer.Models; namespace MyFirstMcpServer.Services; public class McpServer { private readonly Dictionarystring, FuncJsonElement, Taskobject? _methodHandlers; public McpServer() { _methodHandlers new Dictionarystring, FuncJsonElement, Taskobject? { [initialize] HandleInitializeAsync, [tools/list] HandleToolsListAsync, [tools/call] HandleToolsCallAsync, // ... 注册其他方法处理器 }; } public async Task RunAsync(CancellationToken cancellationToken default) { // 使用 Console.OpenStandardInput/Output 获取流支持异步读写 using var inputStream Console.OpenStandardInput(); using var outputStream Console.OpenStandardOutput(); var reader new StreamReader(inputStream); var writer new StreamWriter(outputStream) { AutoFlush true }; while (!cancellationToken.IsCancellationRequested) { try { var line await reader.ReadLineAsync(cancellationToken); if (line null) break; // 流结束 if (string.IsNullOrWhiteSpace(line)) continue; // 反序列化 JSON-RPC 请求 var request JsonSerializer.DeserializeJsonRpcRequest(line); if (request null || request.Method null) continue; // 根据方法名分发给对应的处理器 if (_methodHandlers.TryGetValue(request.Method, out var handler)) { var result await handler(request.Params ?? default(JsonElement)); var response new JsonRpcSuccessResponse { Id request.Id, Result result }; var responseJson JsonSerializer.Serialize(response); await writer.WriteLineAsync(responseJson); } else { // 方法未找到返回错误 var errorResponse new JsonRpcErrorResponse { /* ... 构造错误信息 */ }; await writer.WriteLineAsync(JsonSerializer.Serialize(errorResponse)); } } catch (Exception ex) { // 记录日志并返回一个 JSON-RPC 错误 // 注意不能因为单个请求异常而让整个服务器崩溃 Console.Error.WriteLine($Error processing request: {ex.Message}); } } } private async Taskobject? HandleInitializeAsync(JsonElement params) { // 返回服务器能力信息如协议版本、支持的特性等 return new { protocolVersion 2024-11-05, capabilities new { /* ... */ }, serverInfo new { name MyFirstMcpServer, version 1.0.0 } }; } private async Taskobject? HandleToolsListAsync(JsonElement params) { // 返回服务器提供的所有工具列表 var tools new ListTool { new Tool(read_file, Reads the contents of a file, new InputSchema(...)), // ... 其他工具 }; return new { tools }; } private async Taskobject? HandleToolsCallAsync(JsonElement params) { // 解析参数调用具体的工具逻辑并返回结果 // params 应包含 name 和 arguments var toolName params.GetProperty(name).GetString(); var arguments params.GetProperty(arguments); // 根据 toolName 分发到具体的工具执行器 // ... return new { /* 工具执行结果 */ }; } }注意事项JSON-RPC 要求每个请求都有一个id响应必须携带相同的id。务必在响应中正确设置。此外通信是全双工的但基于行的 JSON 消息。确保每条消息以换行符结束并且使用AutoFlush或手动调用FlushAsync来立即发送数据避免客户端等待。4.2 依赖注入与配置管理为了构建一个可测试、可扩展的服务器我们应该利用 .NET 的依赖注入容器。修改Program.cs并使用Host.CreateDefaultBuilder// Program.cs using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using MyFirstMcpServer.Services; using MyFirstMcpServer.Tools; var host Host.CreateDefaultBuilder(args) .ConfigureServices((context, services) { // 注册服务器为单例 services.AddSingletonMcpServer(); // 注册所有工具 services.AddSingletonIFileSystemTool, FileReadTool(); // 可以在这里添加配置 IConfiguration }) .Build(); // 从容器中获取服务器并运行 var server host.Services.GetRequiredServiceMcpServer(); await server.RunAsync();这样我们可以在McpServer的构造函数中注入所有需要的工具服务使代码更清晰、更易于单元测试。5. 具体工具实现以文件读取为例理论讲完了我们来实现一个最实用、最基础的工具读取本地文件。这个工具将允许 AI 助手在获得用户明确许可和路径后查看你指定文件的内容。5.1 定义工具接口与实现类首先创建一个通用的工具接口便于管理// Tools/ITool.cs namespace MyFirstMcpServer.Tools; public interface ITool { string Name { get; } string Description { get; } InputSchema InputSchema { get; } Taskobject? ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken); }然后实现文件读取工具// Tools/FileReadTool.cs using System.Text.Json; using MyFirstMcpServer.Models.Types; namespace MyFirstMcpServer.Tools; public class FileReadTool : ITool { public string Name read_file; public string Description Reads the contents of a text file from the local filesystem. The user must provide the full path.; public InputSchema InputSchema new InputSchema( type: object, properties: new Dictionarystring, PropertySchema { [path] new PropertySchema( type: string, description: The absolute path to the file to read. ), [encoding] new PropertySchema( type: string, description: The text encoding to use (e.g., utf-8, ascii). Defaults to utf-8., enum: new Liststring { utf-8, ascii, utf-16 } ) }, required: new Liststring { path } ); public async Taskobject? ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken) { // 1. 参数验证与解析 if (!arguments.TryGetProperty(path, out var pathElement) || string.IsNullOrWhiteSpace(pathElement.GetString())) { throw new ArgumentException(The path argument is required and cannot be empty.); } var filePath pathElement.GetString()!; var encodingName utf-8; if (arguments.TryGetProperty(encoding, out var encodingElement)) { encodingName encodingElement.GetString() ?? encodingName; } Encoding encoding encodingName.ToLowerInvariant() switch { ascii Encoding.ASCII, utf-16 Encoding.Unicode, _ Encoding.UTF8 // 默认 UTF-8 }; // 2. 安全检查至关重要 // 绝对禁止访问系统关键目录或进行路径遍历攻击 var fullPath Path.GetFullPath(filePath); var safeBaseDirectory Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); // 示例限制在用户目录下 if (!fullPath.StartsWith(safeBaseDirectory, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException($Access to path {filePath} is not allowed. Files must be within the user directory.); } // 3. 文件操作与异常处理 try { if (!File.Exists(fullPath)) { return new { error $File not found: {fullPath} }; } // 对于大文件考虑流式读取或限制大小 var fileInfo new FileInfo(fullPath); const long maxFileSize 10 * 1024 * 1024; // 10 MB 限制 if (fileInfo.Length maxFileSize) { return new { error $File is too large ({fileInfo.Length} bytes). Maximum allowed size is {maxFileSize} bytes. }; } // 4. 读取文件内容 var content await File.ReadAllTextAsync(fullPath, encoding, cancellationToken); return new { content, filePath fullPath, size fileInfo.Length }; } catch (IOException ioEx) { return new { error $IO Error reading file: {ioEx.Message} }; } catch (UnauthorizedAccessException authEx) { return new { error $Permission denied: {authEx.Message} }; } catch (Exception ex) { // 记录内部日志返回用户友好信息 Console.Error.WriteLine($Tool {Name} error: {ex}); return new { error An internal error occurred while reading the file. }; } } }5.2 工具注册与调用分发现在我们需要修改McpServer类使其能够管理并调用这些工具。在构造函数中注入一个IEnumerableITool// Services/McpServer.cs (部分修改) public class McpServer { private readonly Dictionarystring, ITool _tools; public McpServer(IEnumerableITool tools) { _tools tools.ToDictionary(t t.Name, t t); // ... 其他初始化 } private async Taskobject? HandleToolsListAsync(JsonElement params) { var toolList _tools.Values.Select(t new { name t.Name, description t.Description, inputSchema t.InputSchema }).ToList(); return new { tools toolList }; } private async Taskobject? HandleToolsCallAsync(JsonElement params) { var toolName params.GetProperty(name).GetString(); var arguments params.GetProperty(arguments); if (!_tools.TryGetValue(toolName!, out var tool)) { throw new InvalidOperationException($Tool {toolName} not found.); } var result await tool.ExecuteAsync(arguments, CancellationToken.None); return new { content new[] { new { type text, text JsonSerializer.Serialize(result) } } }; } }核心安全提醒文件读取工具的实现中路径安全验证是重中之重。永远不要相信客户端传入的路径。必须使用Path.GetFullPath解析完整路径避免相对路径如../../../etc/passwd攻击。定义一个安全的根目录如用户文档目录、项目工作区并验证解析后的完整路径是否位于此目录下。考虑文件大小限制防止服务器内存被大文件耗尽。做好异常处理返回给客户端的错误信息应友好避免泄露内部路径或系统信息。6. 集成测试与客户端配置服务器写好了怎么知道它能不能用呢我们需要进行测试。有两种主要方式使用真实的 MCP 客户端如 Claude Desktop进行集成测试或者编写一个简单的测试客户端脚本。6.1 使用 Claude Desktop 进行端到端测试这是最直观的测试方法。假设我们的项目编译后生成的可执行文件是MyFirstMcpServer.exeWindows或MyFirstMcpServerLinux/macOS。找到 Claude Desktop 的配置目录macOS:~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:%APPDATA%\Claude\claude_desktop_config.jsonLinux:~/.config/Claude/claude_desktop_config.json编辑配置文件在mcpServers部分添加我们的服务器配置。配置格式如下{ mcpServers: { my-file-server: { command: dotnet, args: [ /absolute/path/to/your/MyFirstMcpServer.dll ], env: { // 可选的环境变量 } } } }如果你将项目发布为自包含的可执行文件command可以直接指向那个可执行文件无需dotnet和args。重启 Claude Desktop保存配置文件后完全退出并重新启动 Claude Desktop。验证连接在 Claude 的聊天窗口中你可以尝试输入诸如“你能用什么工具”或者直接说“请读取/Users/YourName/Documents/note.txt文件的内容”。如果配置正确Claude 应该能识别出read_file工具并在获得你确认后执行它。6.2 编写简易测试脚本进行调试在开发过程中频繁重启 Claude Desktop 可能比较慢。我们可以编写一个简单的 C# 脚本或控制台程序来模拟客户端直接向我们的服务器进程发送 JSON-RPC 请求并打印响应。这有助于快速调试协议逻辑。// TestClient.cs (一个单独的控制台项目) using System.Diagnostics; using System.Text; using System.Text.Json; var serverProcess new Process { StartInfo new ProcessStartInfo { FileName dotnet, Arguments run --project ../MyFirstMcpServer/MyFirstMcpServer.csproj, RedirectStandardInput true, RedirectStandardOutput true, RedirectStandardError true, UseShellExecute false, CreateNoWindow true } }; serverProcess.Start(); var stdOut serverProcess.StandardOutput; var stdIn serverProcess.StandardInput; // 1. 发送 initialize 请求 var initRequest new { jsonrpc 2.0, id 1, method initialize, params new { protocolVersion 2024-11-05, clientInfo new { name TestClient, version 1.0 } } }; await SendRequestAsync(stdIn, initRequest); var initResponse await ReadResponseAsync(stdOut); Console.WriteLine($Init Response: {initResponse}); // 2. 发送 tools/list 请求 var listRequest new { jsonrpc 2.0, id 2, method tools/list }; await SendRequestAsync(stdIn, listRequest); var listResponse await ReadResponseAsync(stdOut); Console.WriteLine($Tools List: {listResponse}); // 3. 发送 tools/call 请求 (调用 read_file) var callRequest new { jsonrpc 2.0, id 3, method tools/call, params new { name read_file, arguments new { path /tmp/test.txt } // 测试一个存在的文件 } }; await SendRequestAsync(stdIn, callRequest); var callResponse await ReadResponseAsync(stdOut); Console.WriteLine($Tool Call Result: {callResponse}); serverProcess.Kill(); static async Task SendRequestAsync(StreamWriter writer, object request) { var json JsonSerializer.Serialize(request); await writer.WriteLineAsync(json); await writer.FlushAsync(); } static async Taskstring ReadResponseAsync(StreamReader reader) { var line await reader.ReadLineAsync(); return line ?? string.Empty; }运行这个测试客户端你可以看到服务器返回的原始 JSON 数据从而验证协议通信是否正常工具列表是否正确以及工具调用逻辑是否符合预期。7. 进阶功能与扩展思路一个基本的文件读取 MCP 服务器已经完成。但mehrandvd/tutorial-mcp-server-dotnet项目的价值在于它提供了一个可扩展的框架。你可以在此基础上轻松添加更多强大的工具和资源。7.1 实现更多实用工具文件搜索工具接收一个目录路径和关键词返回匹配的文件列表。这需要用到System.IO.Directory.EnumerateFiles和简单的字符串匹配或正则表达式。执行 Shell 命令工具需极度谨慎允许 AI 在受控环境下执行简单的系统命令如git status,ls -la。必须严格限制可执行的命令白名单并做好参数过滤和超时控制这是安全风险最高的工具之一。数据库查询工具连接到一个预设的数据库如 SQLite、PostgreSQL允许 AI 执行安全的 SELECT 查询严禁执行 UPDATE/DELETE。可以使用 Dapper 或 Entity Framework Core 来简化数据库操作。HTTP 请求工具让 AI 能够调用外部 API。你需要处理 URL 验证、请求头、超时和 JSON 解析。7.2 实现资源Resources除了工具MCP 还定义了资源。资源是只读的、可通过 URI 寻址的数据片段。例如你可以实现一个file://资源让 AI 通过resources/read请求直接获取文件内容这与read_file工具功能重叠但协议路径不同。实现资源需要处理resources/list和resources/read请求。7.3 添加配置与日志一个生产可用的服务器需要良好的配置和日志。配置使用appsettings.json和IConfiguration来管理安全根目录、允许的命令列表、数据库连接字符串等。日志集成Microsoft.Extensions.Logging将服务器运行状态、收到的请求、工具调用详情和错误信息记录到文件或控制台便于调试和监控。7.4 性能与稳定性优化异步处理确保所有 I/O 操作文件读写、网络请求、数据库查询都是异步的避免阻塞主线程。取消令牌在长时间运行的工具方法中支持CancellationToken允许客户端取消操作。请求限流防止客户端在短时间内发送大量请求导致服务器过载。进程生命周期管理确保服务器在收到退出信号或客户端断开连接时能优雅关闭释放所有资源。8. 部署与持续集成最后当我们开发完成一个功能丰富的 MCP 服务器后可以考虑如何交付和部署。发布为独立可执行文件使用dotnet publish -c Release -r win-x64 --self-contained命令将项目发布为特定平台的自包含包。这样最终用户无需安装 .NET 运行时即可运行。Docker 容器化创建Dockerfile将服务器打包成 Docker 镜像。这简化了在不同环境中的部署也便于与 Claude Desktop 的 Docker 支持集成。CI/CD 管道在 GitHub Actions 或 Azure DevOps 中设置自动化构建、测试和发布流程。每次推送代码到主分支自动运行单元测试、集成测试并发布新的可执行文件或 Docker 镜像。创建安装脚本或包对于非技术用户可以编写简单的安装脚本如 Shell 脚本或 PowerShell 脚本自动下载最新版本并配置到 Claude Desktop。通过mehrandvd/tutorial-mcp-server-dotnet这个项目我们不仅学会了如何构建一个 .NET MCP 服务器更重要的是掌握了一种让 AI 安全、可控地与真实世界交互的标准方法。这套模式可以无限扩展无论是连接内部业务系统、物联网设备还是复杂的云服务你都可以通过定义相应的 MCP 工具和资源为 AI 助手赋予强大的“行动力”。从今天开始尝试为你最常使用的系统或数据源创建一个 MCP 接口吧你会发现一个全新的、高效的 AI 协作方式正在眼前展开。