独胡 發表於 2025-11-25 23:59:00

使用.NET开发并上线一个小智AI对话机器人的MCP服务转接平台

<h2 id="前言">前言</h2>
<p>最近小智AI对话机器人在ESP32社区实在是太火了,看过之前文章的小伙伴应该都知道之前有给桌面机器人开发过一个.NET客户端,所以对小智也算是比较熟悉。小智虽然支持MCP(Model Context Protocol)协议来扩展功能,但是小智的MCP端点是一个特殊的WebSocket服务,如果想要为小智开发MCP功能,就需要针对这个特殊的端点进行开发。</p>
<p>于是就想着能不能做一个转接平台,让开发者可以专注于标准MCP服务的开发,而不用关心小智特殊的WebSocket协议细节。这个平台可以将标准的MCP服务聚合后通过WebSocket提供给小智,同时支持多租户,每个用户都可以配置自己专属的MCP服务。</p>
<p>项目已经上线并开源了,大家可以直接访问 https://xiaozhi.verdure-hiro.cn 体验,也可以在GitHub上找到完整源码:verdure-mcp-for-xiaozhi</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202511/1690009-20251125233400753-303203725.jpg" alt="img" loading="lazy"></p>
<h3 id="-视频演示">📺 视频演示</h3>
<p>想快速了解项目功能和使用方法?观看我的B站视频教程:</p>
<table>
<thead>
<tr>
<th>B站视频内容</th>
<th>内容简介</th>
<th>适合人群</th>
</tr>
</thead>
<tbody>
<tr>
<td>小智 MCP 转接服务上线与开源</td>
<td>平台介绍、功能演示、在线使用教程</td>
<td>小智商家和小智爱好者</td>
</tr>
<tr>
<td>私有化部署与米家智能家居控制</td>
<td>Docker部署教程、米家MCP服务接入实战</td>
<td>需要私有部署和智能家居控制的用户</td>
</tr>
</tbody>
</table>
<h2 id="为什么要做这个项目">为什么要做这个项目</h2>
<h3 id="技术背景">技术背景</h3>
<p>小智AI的MCP端点采用的是WebSocket协议,这是一个特殊的实现方式,与标准的MCP协议(基于HTTP/SSE)有所不同。如果想要为小智开发MCP功能,开发者需要:</p>
<ol>
<li><strong>了解WebSocket协议细节</strong>:需要处理连接管理、心跳检测、重连机制等</li>
<li><strong>实现MCP协议转换</strong>:将标准MCP的HTTP/SSE请求转换为WebSocket消息</li>
<li><strong>处理工具聚合</strong>:如果要使用多个MCP服务,需要自己实现工具列表的聚合和路由</li>
</ol>
<h3 id="解决方案设计">解决方案设计</h3>
<p>基于这些技术需求,设计了一个MCP服务转接平台来简化开发:</p>
<ul>
<li><strong>协议转换</strong>:自动将标准MCP服务(HTTP/SSE)转换为小智的WebSocket协议</li>
<li><strong>多租户架构</strong>:每个用户都有独立的配置空间,互不干扰</li>
<li><strong>可视化管理</strong>:通过Web界面管理MCP服务配置,无需手动编辑配置文件</li>
<li><strong>服务聚合</strong>:平台作为中间层,将多个MCP服务的工具聚合后提供给小智</li>
<li><strong>分布式支持</strong>:支持多实例部署,通过Redis实现分布式协调</li>
</ul>
<h2 id="从这个项目能学到什么">从这个项目能学到什么</h2>
<h3 id="核心技术栈">核心技术栈</h3>
<ul>
<li><strong>.NET 9</strong> - 新的.NET框架,性能和开发体验都很棒(准备过段时间升级.NET 10)</li>
<li><strong>Blazor WebAssembly</strong> - 纯C#开发前端,无需学习JavaScript</li>
<li><strong>领域驱动设计(DDD)</strong> - 规范的分层架构和领域模型</li>
<li><strong>仓储模式</strong> - 数据访问层的最佳实践</li>
<li><strong>Keycloak认证</strong> - OpenID Connect标准的身份认证</li>
<li><strong>WebSocket编程</strong> - 实时双向通信的实现</li>
<li><strong>分布式协调</strong> - 基于Redis的分布式锁和状态管理</li>
</ul>
<h3 id="架构亮点">架构亮点</h3>
<p>这个项目展示了企业级.NET应用的完整架构:</p>
<pre><code>verdure-mcp-for-xiaozhi/
├── Domain/         # 领域层:聚合根、实体、仓储接口
├── Application/      # 应用服务层:业务逻辑编排
├── Infrastructure/   # 基础设施层:数据访问、外部服务
├── Api/             # API层:RESTful接口、WebSocket服务
└── Web/             # Blazor前端:组件化UI开发
</code></pre>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202511/1690009-20251125224148514-1786920132.jpg" alt="平台界面展示" loading="lazy"></p>
<h2 id="核心设计理念">核心设计理念</h2>
<h3 id="领域模型设计">领域模型设计</h3>
<p>项目采用DDD设计,核心有两个聚合根:</p>
<h4 id="1-xiaozhimcpendpoint小智连接端点">1. XiaozhiMcpEndpoint(小智连接端点)</h4>
<p>代表用户配置的小智WebSocket连接地址,是整个系统的核心实体。</p>
<pre><code class="language-csharp">public class XiaozhiMcpEndpoint : Entity, IAggregateRoot
{
    public string Name { get; private set; }
    public string Address { get; private set; }// WebSocket地址
    public string UserId { get; private set; }
    public string? Description { get; private set; }
    public bool IsEnabled { get; private set; }// 是否启用连接
    public bool IsConnected { get; private set; } // 实时连接状态
   
    // 时间戳追踪
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }
    public DateTime? LastConnectedAt { get; private set; }
    public DateTime? LastDisconnectedAt { get; private set; }
   
    // 服务绑定集合 - 私有字段,只读暴露
    private readonly List&lt;McpServiceBinding&gt; _serviceBindings = new();
    public IReadOnlyCollection&lt;McpServiceBinding&gt; ServiceBindings =&gt; _serviceBindings.AsReadOnly();
   
    public XiaozhiMcpEndpoint(string name, string address, string userId, string? description = null)
    {
      GenerateId(); // 使用Guid Version 7生成ID
      Name = name;
      Address = address;
      UserId = userId;
      Description = description;
      IsEnabled = false; // 默认禁用,需用户主动启用
      IsConnected = false;
      CreatedAt = DateTime.UtcNow;
    }
   
    // 核心业务方法:启用端点
    public void Enable()
    {
      IsEnabled = true;
      UpdatedAt = DateTime.UtcNow;
    }
   
    // 核心业务方法:禁用端点并断开连接
    public void Disable()
    {
      IsEnabled = false;
      IsConnected = false;
      UpdatedAt = DateTime.UtcNow;
    }
   
    // ...其他方法: SetConnected(), SetDisconnected(), UpdateInfo()等已省略
}
</code></pre>
<p><strong>设计要点</strong>:</p>
<ul>
<li>使用私有setter保护数据完整性</li>
<li>通过方法(Enable/Disable)而非直接修改属性来改变状态</li>
<li>IsEnabled和IsConnected分离:IsEnabled是用户意图,IsConnected是实际状态</li>
<li>ServiceBindings集合封装:私有List配合只读接口暴露,防止外部直接修改</li>
</ul>
<h4 id="2-mcpserviceconfigmcp服务配置">2. McpServiceConfig(MCP服务配置)</h4>
<p>代表一个可用的MCP服务节点及其认证配置。</p>
<pre><code class="language-csharp">public class McpServiceConfig : Entity, IAggregateRoot
{
    public string Name { get; private set; }
    public string Endpoint { get; private set; }
    public string UserId { get; private set; }
    public string? Description { get; private set; }
    public bool IsPublic { get; private set; }
   
    // 认证配置支持4种类型: bearer, basic, apikey, oauth2
    public string? AuthenticationType { get; private set; }
    public string? AuthenticationConfig { get; private set; } // JSON格式配置
   
    public string? Protocol { get; private set; } // stdio/http/sse
   
    // 时间戳
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }
    public DateTime? LastSyncedAt { get; private set; }
   
    // 工具集合 - 私有字段,只读暴露
    private readonly List&lt;McpTool&gt; _tools = new();
    public IReadOnlyCollection&lt;McpTool&gt; Tools =&gt; _tools.AsReadOnly();
   
    public McpServiceConfig(
      string name,
      string endpoint,
      string userId,
      string? description = null,
      string? authenticationType = null,
      string? authenticationConfig = null,
      string? protocol = null)
    {
      GenerateId();
      Name = name;
      Endpoint = endpoint;
      UserId = userId;
      Description = description;
      IsPublic = false; // 默认私有
      AuthenticationType = authenticationType;
      AuthenticationConfig = authenticationConfig;
      Protocol = protocol ?? "stdio"; // 默认stdio协议
      CreatedAt = DateTime.UtcNow;
    }
   
    // 更新服务配置信息
    public void UpdateInfo(
      string name,
      string endpoint,
      string? description = null,
      string? authenticationType = null,
      string? authenticationConfig = null,
      string? protocol = null)
    {
      Name = name;
      Endpoint = endpoint;
      Description = description;
      AuthenticationType = authenticationType;
      AuthenticationConfig = authenticationConfig;
      Protocol = protocol;
      UpdatedAt = DateTime.UtcNow;
    }
   
    public void SetPublic()
    {
      IsPublic = true;
      UpdatedAt = DateTime.UtcNow;
    }
   
    public void SetPrivate()
    {
      IsPublic = false;
      UpdatedAt = DateTime.UtcNow;
    }
   
    // ...其他方法: UpdateAuthenticationConfig()等已省略
}
</code></pre>
<p><strong>设计要点</strong>:</p>
<ul>
<li>支持4种认证方式(Bearer/Basic/API Key/OAuth2),认证配置以JSON存储保持灵活性</li>
<li>通过SetPublic/SetPrivate方法控制服务可见性,支持公共服务市场</li>
<li>Tools集合封装:私有List配合只读接口暴露,防止外部直接修改</li>
<li>使用Guid Version 7作为ID,提供更好的数据库索引性能和时序特性</li>
</ul>
<h3 id="websocket会话管理">WebSocket会话管理</h3>
<p>这是整个平台最核心的部分,需要处理几个关键问题:</p>
<h4 id="问题1如何聚合多个mcp服务的工具">问题1:如何聚合多个MCP服务的工具?</h4>
<p><strong>解决方案</strong>:McpSessionService维护多个McpClient实例</p>
<pre><code class="language-csharp">public class McpSessionService : IAsyncDisposable
{
    private readonly ILogger&lt;McpSessionService&gt; _logger;
    private readonly IMcpClientService _mcpClientService;
    private readonly McpSessionConfiguration _config;
    private readonly ReconnectionSettings _reconnectionSettings;
   
    // Session state
    private ClientWebSocket? _webSocket;
    private readonly List&lt;McpClient&gt; _mcpClients = new();
    // 🔧 追踪每个客户端的服务配置,用于会话恢复
    private readonly Dictionary&lt;int, McpServiceEndpoint&gt; _clientIndexToServiceConfig = new();
    // 🔧 追踪失败的服务,用于定期重试
    private readonly Dictionary&lt;string, (McpServiceEndpoint Config, DateTime LastAttempt)&gt; _failedServices = new();
   
    // Ping timeout monitoring
    private DateTime _lastPingReceivedTime = DateTime.UtcNow;
    private readonly TimeSpan _pingTimeout = TimeSpan.FromSeconds(120);
   
    // Connection status events
    public event Func&lt;Task&gt;? OnConnected;
    public event Func&lt;string, Task&gt;? OnConnectionFailed;
    public event Func&lt;Task&gt;? OnDisconnected;
   
    private async Task ConnectAsync(CancellationToken cancellationToken)
    {
      // ⚠️ 关键:先连接MCP服务,再连接WebSocket
      // 这确保所有后端服务就绪后才告知小智我们在线
      
      _logger.LogInformation("Server {ServerId}: Connecting to {Count} MCP service(s)...",
            ServerId, _config.McpServices.Count);
      
      var failedServiceNames = new List&lt;string&gt;();
      
      // 1. 先连接所有MCP服务(支持多种认证方式)
      foreach (var service in _config.McpServices)
      {
            try
            {
                // 创建MCP客户端,传递认证配置
                var mcpClient = await _mcpClientService.CreateMcpClientAsync(
                  $"McpService_{service.ServiceName}",
                  service.NodeAddress,
                  service.Protocol ?? "stdio",
                  service.AuthenticationType,// bearer/basic/apikey/oauth2
                  service.AuthenticationConfig, // JSON格式认证配置
                  cancellationToken);
               
                var clientIndex = _mcpClients.Count;
                _mcpClients.Add(mcpClient);
                _clientIndexToServiceConfig = service; // 追踪配置用于会话恢复
               
                _logger.LogInformation("Server {ServerId}: Connected to MCP service {ServiceName}",
                  ServerId, service.ServiceName);
            }
            catch (Exception ex)
            {
                // 记录失败的服务,供后续重试
                failedServiceNames.Add(service.ServiceName);
                _failedServices = (service, DateTime.UtcNow);
                _logger.LogWarning("Server {ServerId}: Skipping MCP service {ServiceName} - {Error}",
                  ServerId, service.ServiceName, ex.Message);
            }
      }
      
      // 检查是否至少有一个MCP客户端连接成功
      if (_mcpClients.Count == 0)
      {
            throw new InvalidOperationException(
                $"No MCP clients connected successfully (0/{_config.McpServices.Count})");
      }
      
      _logger.LogInformation("Server {ServerId}: {SuccessCount}/{TotalCount} MCP services connected",
            ServerId, _mcpClients.Count, _config.McpServices.Count);
      
      // 2. 所有MCP服务就绪后,连接小智WebSocket
      _webSocket = new ClientWebSocket();
      await _webSocket.ConnectAsync(new Uri(_config.WebSocketEndpoint), cancellationToken);
      
      _lastPingReceivedTime = DateTime.UtcNow; // 初始化ping监控
      
      await (OnConnected?.Invoke() ?? Task.CompletedTask); // 触发连接成功回调
      
      // 3. 启动双向通信 + ping超时监控
      // ...消息管道和监控逻辑已省略
    }
}
</code></pre>
<p><strong>设计要点</strong>:</p>
<ul>
<li><strong>List而非Dictionary</strong>:<code>_mcpClients</code>使用List存储,通过索引映射到配置</li>
<li><strong>失败服务跟踪</strong>:<code>_failedServices</code>记录失败的服务供后续重试</li>
<li><strong>Ping超时监控</strong>:120秒未收到ping则认为连接断开</li>
<li><strong>连接顺序关键</strong>:先MCP服务,再WebSocket,确保后端就绪</li>
<li><strong>事件驱动</strong>:通过OnConnected/OnConnectionFailed/OnDisconnected通知上层</li>
</ul>
<h4 id="问题2小智请求工具列表怎么响应">问题2:小智请求工具列表怎么响应?</h4>
<p><strong>解决方案</strong>:从配置数据直接获取工具信息,不依赖MCP客户端连接状态</p>
<pre><code class="language-csharp">private async Task HandleToolsListAsync(int? id, CancellationToken cancellationToken)
{
    // ⚡ 性能优化: 直接从配置读取工具列表,不依赖MCP客户端连接状态
    // 即使部分MCP服务连接失败,也能返回已配置的完整工具列表
   
    if (_config.McpServices.Count == 0)
    {
      await SendErrorResponseAsync(id, -32603, "No MCP services configured",
            "No MCP service bindings configured for this endpoint", cancellationToken);
      return;
    }

    var allTools = new List&lt;object&gt;();
   
    // 遍历所有配置的服务,聚合SelectedTools
    foreach (var serviceConfig in _config.McpServices)
    {
      foreach (var tool in serviceConfig.SelectedTools)
      {
            // 解析存储的InputSchema JSON(完整的工具Schema已在工具同步时保存)
            var properties = new Dictionary&lt;string, object&gt;();
            var required = Array.Empty&lt;string&gt;();

            if (!string.IsNullOrEmpty(tool.InputSchema))
            {
                var schemaDoc = JsonDocument.Parse(tool.InputSchema);
                if (schemaDoc.RootElement.TryGetProperty("properties", out var propsElement))
                {
                  properties = JsonElementToObject(propsElement) as Dictionary&lt;string, object&gt; ?? new();
                }
                if (schemaDoc.RootElement.TryGetProperty("required", out var reqElement))
                {
                  required = reqElement.EnumerateArray()
                        .Select(x =&gt; x.GetString() ?? "")
                        .ToArray();
                }
            }

            // 构建符合MCP协议的工具定义
            allTools.Add(new
            {
                name = tool.Name,
                description = tool.Description,
                inputSchema = new
                {
                  type = "object",
                  properties = properties,
                  required = required,
                  title = $"{tool.Name}Arguments"
                }
            });
      }
    }
   
    // 返回JSON-RPC格式响应
    var response = new
    {
      jsonrpc = "2.0",
      id = id,
      result = new { tools = allTools.ToArray() }
    };
   
    await SendWebSocketResponseAsync(response, cancellationToken);
}
</code></pre>
<p><strong>关键优化</strong>:</p>
<ul>
<li>直接从配置读取工具数据,即使MCP客户端连接失败也能返回工具列表</li>
<li>完整解析InputSchema的properties和required字段</li>
<li>符合MCP协议的工具schema格式要求</li>
</ul>
<h4 id="问题3小智调用工具怎么路由到对应的mcp服务">问题3:小智调用工具怎么路由到对应的MCP服务?</h4>
<p><strong>解决方案</strong>:根据工具名称查找对应的McpClient</p>
<pre><code class="language-csharp">private async Task HandleToolsCallAsync(int? id, JsonDocument request, CancellationToken cancellationToken)
{
    var toolName = request.RootElement.GetProperty("params")
      .GetProperty("name").GetString();
    var arguments = request.RootElement.GetProperty("params")
      .GetProperty("arguments");
   
    // 🔍 遍历所有MCP客户端,查找包含该工具的服务
    for (int i = 0; i &lt; _mcpClients.Count; i++)
    {
      var mcpClient = _mcpClients;
      var serviceConfig = _clientIndexToServiceConfig;
      
      // 如果配置了SelectedTools,则只在选中的工具中查找
      var selectedTools = serviceConfig.SelectedTools;
      if (selectedTools.Any())
      {
            var isToolSelected = selectedTools.Any(t =&gt; t.Name == toolName);
            if (!isToolSelected) continue; // 工具未被选中,跳过此服务
      }
      
      // 检查此MCP客户端是否提供该工具
      var hasTool = mcpClient.Tools?.Any(t =&gt; t.Name == toolName) ?? false;
      if (!hasTool) continue;
      
      // 找到目标服务,调用工具
      try
      {
            var result = await mcpClient.CallToolAsync(
                toolName,
                JsonSerializer.Deserialize&lt;Dictionary&lt;string, object&gt;&gt;(arguments.GetRawText())!,
                cancellationToken: cancellationToken);
            
            // 返回工具调用结果
            await SendWebSocketResponseAsync(new
            {
                jsonrpc = "2.0",
                id = id,
                result = result
            }, cancellationToken);
            
            return; // 成功调用,结束
      }
      catch (Exception ex)
      {
            _logger.LogError(ex, "Tool call failed for {ToolName} on service {ServiceName}",
                toolName, serviceConfig.ServiceName);
            // 继续尝试下一个服务
      }
    }
   
    // 未找到提供该工具的服务
    await SendErrorResponseAsync(id, -32601, "Tool not found",
      $"No MCP service provides tool '{toolName}'", cancellationToken);
}
</code></pre>
<h3 id="mcp服务认证支持">MCP服务认证支持</h3>
<p>为了支持各种需要认证的MCP服务,我抽象出了统一的认证助手类:</p>
<pre><code class="language-csharp">/// &lt;summary&gt;
/// MCP认证配置助手类
/// 被McpClientService(工具同步)和McpSessionService(WebSocket连接)共享使用
/// &lt;/summary&gt;
public static class McpAuthenticationHelper
{
    /// &lt;summary&gt;
    /// 为Bearer、Basic和API Key认证构建HTTP请求头
    /// &lt;/summary&gt;
    public static Dictionary&lt;string, string&gt; BuildAuthenticationHeaders(
      string authenticationType,
      string authenticationConfig,
      ILogger? logger = null)
    {
      if (string.IsNullOrEmpty(authenticationType) || string.IsNullOrEmpty(authenticationConfig))
      {
            throw new ArgumentException("Authentication type and config cannot be null or empty");
      }

      try
      {
            var authType = authenticationType.ToLowerInvariant();

            return authType switch
            {
                "bearer" =&gt; BuildBearerTokenHeaders(authenticationConfig, logger),
                "basic" =&gt; BuildBasicAuthHeaders(authenticationConfig, logger),
                "apikey" =&gt; BuildApiKeyHeaders(authenticationConfig, logger),
                _ =&gt; throw new InvalidOperationException(
                  $"Unsupported authentication type: {authenticationType}. Use BuildOAuth2Options for OAuth 2.0.")
            };
      }
      catch (Exception ex)
      {
            logger?.LogError(ex, "Failed to build authentication headers for type {AuthType}", authenticationType);
            throw new InvalidOperationException($"Failed to configure authentication: {ex.Message}", ex);
      }
    }
   
    /// &lt;summary&gt;
    /// 为OAuth 2.0构建SDK的ClientOAuthOptions配置
    /// &lt;/summary&gt;
    public static ClientOAuthOptions BuildOAuth2Options(
      string authenticationConfig,
      ILogger? logger = null)
    {
      var authConfig = JsonSerializer.Deserialize&lt;OAuth2AuthConfig&gt;(authenticationConfig);
      
      if (string.IsNullOrEmpty(authConfig?.ClientId) || string.IsNullOrEmpty(authConfig.RedirectUri))
      {
            throw new InvalidOperationException("OAuth 2.0 Client ID and Redirect URI are required");
      }

      logger?.LogDebug("Configuring OAuth 2.0 with Client ID: {ClientId}", authConfig.ClientId);

      var oauthOptions = new ClientOAuthOptions
      {
            RedirectUri = new Uri(authConfig.RedirectUri),
            ClientId = authConfig.ClientId,
            ClientSecret = authConfig.ClientSecret
      };

      if (!string.IsNullOrEmpty(authConfig.Scope))
      {
            oauthOptions.Scopes = authConfig.Scope.Split(' ', StringSplitOptions.RemoveEmptyEntries);
      }

      return oauthOptions;
    }

    // 私有辅助方法: BuildBearerTokenHeaders, BuildBasicAuthHeaders, BuildApiKeyHeaders
    // ...实现细节已省略(解析JSON配置,构建对应的HTTP请求头)
}
</code></pre>
<p><strong>设计要点</strong>:</p>
<ul>
<li><strong>DRY原则</strong>:工具同步和WebSocket连接都复用这个助手类,消除了150+行重复代码</li>
<li><strong>双方法设计</strong>:
<ul>
<li><code>BuildAuthenticationHeaders</code>:处理Bearer/Basic/API Key(通过HTTP Header传递)</li>
<li><code>BuildOAuth2Options</code>:处理OAuth 2.0(使用SDK的ClientOAuthOptions)</li>
</ul>
</li>
<li><strong>健壮的错误处理</strong>:参数验证、异常捕获、详细错误信息</li>
<li><strong>可选日志</strong>:通过ILogger参数支持调试,不强制依赖</li>
<li><strong>私有辅助方法</strong>:每种认证类型的具体实现封装在私有方法中,保持代码清晰</li>
</ul>
<h3 id="分布式websocket管理">分布式WebSocket管理</h3>
<p>为了支持多实例部署,我使用Redis实现了分布式协调:</p>
<pre><code class="language-csharp">public class McpSessionManager : IAsyncDisposable
{
    private readonly IDistributedLockService _lockService; // RedLock实现
    private readonly IConnectionStateService _connectionStateService; // Redis状态管理
    private readonly Dictionary&lt;string, McpSessionService&gt; _sessions = new();
   
    public async Task&lt;bool&gt; StartSessionAsync(string serverId, CancellationToken cancellationToken = default)
    {
      // 1. 本地检查: 避免不必要的锁竞争
      if (_sessions.ContainsKey(serverId))
      {
            _logger.LogInformation("Server {ServerId} session already exists locally", serverId);
            return false;
      }
      
      // 2. Redis检查: 可能其他实例已连接
      var connectionState = await _connectionStateService.GetConnectionStateAsync(serverId);
      if (connectionState?.Status == ConnectionStatus.Connected)
      {
            _logger.LogInformation("Server {ServerId} is already connected on instance {InstanceId}",
                serverId, connectionState.InstanceId);
            return false;
      }
      
      // 3. 获取分布式锁 (RedLock算法)
      var lockKey = $"mcp:session:lock:{serverId}";
      var acquired = await _lockService.AcquireLockAsync(
            lockKey,
            expiryTime: TimeSpan.FromMinutes(5),
            waitTime: TimeSpan.FromSeconds(10),
            retryTime: TimeSpan.FromSeconds(1));
      
      if (!acquired)
      {
            _logger.LogWarning("Failed to acquire lock for server {ServerId}", serverId);
            return false;
      }
      
      try
      {
            // 4. Double-check: 再次检查Redis状态(持有锁后)
            connectionState = await _connectionStateService.GetConnectionStateAsync(serverId);
            if (connectionState?.Status == ConnectionStatus.Connected)
            {
                _logger.LogInformation("Server {ServerId} connected by another instance during lock wait",
                  serverId);
                return false;
            }
            
            // 5. 构建会话配置(从数据库加载服务绑定、工具等)
            var config = await BuildSessionConfigurationAsync(serverId, cancellationToken);
            
            // 6. 创建会话并订阅事件
            var session = new McpSessionService(config, _mcpClientService, _loggerFactory);
            
            session.OnConnected += async () =&gt;
            {
                await _connectionStateService.RegisterConnectionAsync(serverId, InstanceId);
                await UpdateEndpointStatusAsync(serverId, isConnected: true);
            };
            
            session.OnConnectionFailed += async (error) =&gt;
            {
                await _connectionStateService.UpdateStatusAsync(serverId, ConnectionStatus.Failed);
                await UpdateEndpointStatusAsync(serverId, isConnected: false);
            };
            
            session.OnDisconnected += async () =&gt;
            {
                await _connectionStateService.UnregisterConnectionAsync(serverId);
                await UpdateEndpointStatusAsync(serverId, isConnected: false);
                _sessions.Remove(serverId); // 清理本地会话
            };
            
            _sessions = session;
            
            // 7. 在后台启动会话(不阻塞)
            _ = Task.Run(async () =&gt;
            {
                try
                {
                  await session.ConnectAsync(cancellationToken);
                }
                catch (Exception ex)
                {
                  _logger.LogError(ex, "Session connection failed for {ServerId}", serverId);
                }
            }, cancellationToken);
            
            return true;
      }
      finally
      {
            await _lockService.ReleaseLockAsync(lockKey); // 释放分布式锁
      }
    }
   
    // ...其他方法: StopSessionAsync, BuildSessionConfigurationAsync等已省略
}
</code></pre>
<p><strong>分布式设计要点</strong>:</p>
<ul>
<li><strong>三层检查机制</strong>: 本地字典 → Redis状态 → 分布式锁,最小化锁竞争开销</li>
<li><strong>RedLock算法</strong>: 使用RedLock.net库实现分布式锁,支持多Redis实例容错</li>
<li><strong>Double-Check模式</strong>: 获取锁后再次检查Redis状态,防止竞态条件下的重复连接</li>
<li><strong>事件驱动状态同步</strong>: 通过OnConnected/OnDisconnected事件自动更新Redis和数据库</li>
<li><strong>非阻塞启动</strong>: 会话连接在后台Task中执行,StartSessionAsync立即返回</li>
<li><strong>故障转移支持</strong>: 实例下线时,监控服务可检测到Redis状态变化并在其他实例重启会话</li>
</ul>
<h2 id="blazor前端开发体验">Blazor前端开发体验</h2>
<p>Blazor WebAssembly实现了纯C#全栈开发,前后端统一技术栈:</p>
<pre><code class="language-razor">@page "/connections"
@inject IXiaozhiMcpEndpointClientService ServerService
@inject ISnackbar Snackbar

&lt;MudDataGrid Items="@_servers" Filterable="true"&gt;
    &lt;Columns&gt;
      &lt;PropertyColumn Property="x =&gt; x.Name" Title="名称" /&gt;
      &lt;PropertyColumn Property="x =&gt; x.IsConnected" Title="状态"&gt;
            &lt;CellTemplate&gt;
                @if (context.Item.IsConnected)
                {
                  &lt;MudChip Color="Color.Success" Icon="@Icons.Material.Filled.CheckCircle"&gt;已连接&lt;/MudChip&gt;
                }
                else if (context.Item.IsEnabled)
                {
                  &lt;MudChip Color="Color.Warning"&gt;未连接&lt;/MudChip&gt;
                }
                else
                {
                  &lt;MudChip&gt;已禁用&lt;/MudChip&gt;
                }
            &lt;/CellTemplate&gt;
      &lt;/PropertyColumn&gt;
      &lt;TemplateColumn Title="操作"&gt;
            &lt;CellTemplate&gt;
                @if (context.Item.IsEnabled)
                {
                  &lt;MudIconButton Icon="@Icons.Material.Filled.PowerOff"
                                 Color="Color.Error"
                                 OnClick="@(() =&gt; DisableServerAsync(context.Item.Id!))" /&gt;
                }
                else
                {
                  &lt;MudIconButton Icon="@Icons.Material.Filled.PlayArrow"
                                 Color="Color.Success"
                                 OnClick="@(() =&gt; EnableServerAsync(context.Item.Id!))" /&gt;
                }
            &lt;/CellTemplate&gt;
      &lt;/TemplateColumn&gt;
    &lt;/Columns&gt;
&lt;/MudDataGrid&gt;

@code {
    private IEnumerable&lt;XiaozhiMcpEndpointDto&gt; _servers = Array.Empty&lt;XiaozhiMcpEndpointDto&gt;();
   
    protected override async Task OnInitializedAsync()
    {
      await LoadServersAsync();
    }
   
    private async Task EnableServerAsync(string serverId)
    {
      await ServerService.EnableServerAsync(serverId);
      Snackbar.Add("WebSocket连接已启动", Severity.Success);
      await LoadServersAsync();
    }
}
</code></pre>
<p>使用MudBlazor组件库,界面开发效率很高,而且组件都是Material Design风格,很美观。</p>
<h2 id="部署和上线">部署和上线</h2>
<h3 id="docker单镜像部署">Docker单镜像部署</h3>
<p>项目配置了完整的Docker支持,前后端打包到一个镜像中:</p>
<pre><code class="language-dockerfile"># 基础运行时镜像 - Alpine Linux最小化体积
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS base
WORKDIR /app
EXPOSE 8080 8081
RUN apk add --no-cache curl icu-libs tzdata

# 构建镜像
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src

# 复制项目文件并还原依赖
COPY ["Verdure.McpPlatform.sln", "."]
COPY ["src/Verdure.McpPlatform.Api/Verdure.McpPlatform.Api.csproj", "src/Verdure.McpPlatform.Api/"]
COPY ["src/Verdure.McpPlatform.Web/Verdure.McpPlatform.Web.csproj", "src/Verdure.McpPlatform.Web/"]
# ...其他项目文件已省略

RUN dotnet restore "src/Verdure.McpPlatform.Api/Verdure.McpPlatform.Api.csproj"

# 复制源代码并构建
COPY . .
WORKDIR "/src/src/Verdure.McpPlatform.Api"
RUN dotnet publish "Verdure.McpPlatform.Api.csproj" \
    -c $BUILD_CONFIGURATION \
    -o /app/publish \
    /p:UseAppHost=false

# 最终运行时镜像
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .

ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD curl -f http://localhost:8080/api/health || exit 1

ENTRYPOINT ["dotnet", "Verdure.McpPlatform.Api.dll"]
</code></pre>
<h3 id="配置说明">配置说明</h3>
<p>主要需要配置以下环境变量:</p>
<pre><code class="language-json">{
"ConnectionStrings": {
    "mcpdb": "Host=localhost;Database=verdure_mcp;Username=postgres;Password=***",
    "identitydb": "Host=localhost;Database=verdure_identity;Username=postgres;Password=***",
    "Redis": "localhost:6379"
},
"Database": {
    "Provider": "PostgreSQL",// 或 "SQLite"
    "TablePrefix": "verdure_"// 数据库表名前缀
},
"Identity": {
    "Url": "https://auth.verdure-hiro.cn/realms/maker-community",
    "Realm": "maker-community",
    "ClientId": "verdure-mcp",
    "Audience": "verdure-mcp-api",
    "RequireHttpsMetadata": true,
    "ClockSkewMinutes": 5
},
"ConnectionMonitor": {
    "CheckIntervalSeconds": 30,      // 监控检查间隔
    "HeartbeatTimeoutSeconds": 90,   // 心跳超时时间
    "ReconnectCooldownSeconds": 60   // 重连冷却时间
},
"Logging": {
    "LogLevel": {
      "Default": "Information",
      "Verdure.McpPlatform": "Debug"
    }
}
}
</code></pre>
<p><strong>关键配置说明</strong>:</p>
<ol>
<li>
<p><strong>数据库配置</strong>:</p>
<ul>
<li><code>mcpdb</code>: 业务数据库连接字符串(MCP服务配置、连接端点等)</li>
<li><code>identitydb</code>: 身份认证数据库连接字符串(用户、角色等)</li>
<li><code>Provider</code>: 支持PostgreSQL和SQLite两种数据库</li>
<li><code>TablePrefix</code>: 统一的表名前缀,用于多租户部署</li>
</ul>
</li>
<li>
<p><strong>Redis配置</strong>:</p>
<ul>
<li>用于分布式锁和连接状态管理</li>
<li>生产环境建议配置密码和SSL: <code>"redis:6379,password=***,ssl=true,abortConnect=false"</code></li>
</ul>
</li>
<li>
<p><strong>身份认证配置</strong>:</p>
<ul>
<li><code>Url</code>: Keycloak服务地址,包含realm路径</li>
<li><code>Realm</code>: Keycloak realm名称</li>
<li><code>ClientId</code>: 客户端ID</li>
<li><code>Audience</code>: API受众标识,用于JWT验证</li>
<li><code>ClockSkewMinutes</code>: 时钟偏移容忍度,处理服务器时间差异</li>
</ul>
</li>
<li>
<p><strong>连接监控配置</strong>:</p>
<ul>
<li><code>CheckIntervalSeconds</code>: WebSocket连接健康检查间隔</li>
<li><code>HeartbeatTimeoutSeconds</code>: 心跳超时判定时间</li>
<li><code>ReconnectCooldownSeconds</code>: 断开后重连冷却时间</li>
<li>开发环境可以设置更短的间隔(15秒)以快速检测问题</li>
</ul>
</li>
</ol>
<p><strong>环境变量方式配置</strong>:</p>
<pre><code class="language-bash"># 使用环境变量覆盖配置
ConnectionStrings__mcpdb="Host=prod-db;Database=verdure_mcp;..."
ConnectionStrings__Redis="redis:6379,password=***"
Identity__Url="https://auth.example.com/realms/prod"
ConnectionMonitor__CheckIntervalSeconds=60
</code></pre>
<h2 id="总结与展望">总结与展望</h2>
<p>通过这个项目的开发实践,可以看到.NET生态在全栈开发上的优势:</p>
<ul>
<li><strong>统一的技术栈</strong>:从后端API到前端UI都是C#,降低了学习成本</li>
<li><strong>成熟的框架支持</strong>:EF Core、ASP.NET Core、Blazor等都很完善</li>
<li><strong>企业级特性</strong>:DDD、仓储模式、分布式协调等都有现成的最佳实践可以参考</li>
</ul>
<p>目前平台已经上线并开源,大家可以访问在线服务体验,也欢迎在GitHub上贡献代码。后续计划:</p>
<ul>
<li>添加更多MCP服务的预置模板</li>
<li>实现工具调用的监控和统计功能</li>
<li>开发MCP服务的市场和分享机制</li>
</ul>
<p>这个项目展示了.NET在现代全栈开发中的应用,希望能给想学习.NET技术的小伙伴提供一个实战参考,同时也为小智社区的生态建设贡献一份力量。</p>
<h3 id="-推荐观看视频教程">📺 推荐观看视频教程</h3>
<p>如果你想更直观地了解项目的使用方法和部署流程,强烈推荐观看我的B站视频内容:</p>
<ul>
<li>小智 MCP 转接服务上线与开源 - 快速上手指南</li>
<li>私有化部署与米家智能家居控制 - 深度实战教程</li>
<li>B站主页 @绿荫阿广 - 获取更多AI和创客教程</li>
</ul>
<h2 id="参考推荐">参考推荐</h2>
<ul>
<li>创客社区地址</li>
<li>项目开源仓库</li>
<li>在线体验地址</li>
<li>小智AI官网</li>
<li>MCP协议规范</li>
<li>小智MCP例子</li>
<li>.NET官方文档</li>
<li>Blazor官方文档</li>
<li>MudBlazor组件库</li>
</ul><br><br>
来源:https://www.cnblogs.com/GreenShade/p/19270503
頁: [1]
查看完整版本: 使用.NET开发并上线一个小智AI对话机器人的MCP服务转接平台