| 1 | using System.Collections.Concurrent; |
| 2 | using System.Text.Json; |
| 3 | using ModelContextProtocol.Client; |
| 4 | using ModelContextProtocol.Protocol; |
| 5 | |
| 6 | namespace CascadeIDE.Services; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Клиент к внешним MCP-серверам (tool calling) — автономный режим может списокировать и вызывать тулы. |
| 10 | /// Подключение пока реализовано только через stdio (StdioClientTransport). |
| 11 | /// </summary> |
| 12 | public sealed class McpClientService : IAsyncDisposable |
| 13 | { |
| 14 | private sealed record ServerSpec(string Name, string Command, IReadOnlyList<string> Arguments, string ToolPrefix, bool Enabled); |
| 15 | |
| 16 | private readonly string _externalMcpServersJson; |
| 17 | private readonly ConcurrentDictionary<string, McpClient> _clients = new(StringComparer.OrdinalIgnoreCase); |
| 18 | private readonly ConcurrentDictionary<string, (string ServerName, string ToolName)> _toolsByKey = new(StringComparer.OrdinalIgnoreCase); |
| 19 | |
| 20 | public McpClientService(string externalMcpServersJson) |
| 21 | { |
| 22 | _externalMcpServersJson = externalMcpServersJson ?? "[]"; |
| 23 | } |
| 24 | |
| 25 | public async Task EnsureConnectedAsync(CancellationToken cancellationToken = default) |
| 26 | { |
| 27 | var specs = ParseSpecs(); |
| 28 | foreach (var spec in specs.Where(s => s.Enabled)) |
| 29 | { |
| 30 | cancellationToken.ThrowIfCancellationRequested(); |
| 31 | if (_clients.ContainsKey(spec.Name)) |
| 32 | continue; |
| 33 | |
| 34 | var transport = new StdioClientTransport(new StdioClientTransportOptions |
| 35 | { |
| 36 | Name = spec.Name, |
| 37 | Command = spec.Command, |
| 38 | Arguments = spec.Arguments.ToArray() |
| 39 | }); |
| 40 | |
| 41 | var client = await McpClient.CreateAsync(transport).ConfigureAwait(false); |
| 42 | _clients[spec.Name] = client; |
| 43 | |
| 44 | // Index tool names for fast routing: <toolPrefix>.<toolName> |
| 45 | foreach (var tool in await client.ListToolsAsync().ConfigureAwait(false)) |
| 46 | { |
| 47 | var key = $"{spec.ToolPrefix}.{tool.Name}"; |
| 48 | _toolsByKey[key] = (spec.Name, tool.Name); |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | public async Task<IReadOnlyList<(string ToolKey, string Description)>> ListToolsAsync(CancellationToken cancellationToken = default) |
| 54 | { |
| 55 | await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); |
| 56 | |
| 57 | // We don't have the full tool description without a reverse index; for now, return empty description. |
| 58 | // (Автономный раннер всё равно отправляет модели список через `tools` ключи; описания — опционально.) |
| 59 | return _toolsByKey.Keys.Select(k => (k, "")).ToList(); |
| 60 | } |
| 61 | |
| 62 | public async Task<string> CallToolAsync(string toolKey, IReadOnlyDictionary<string, object?>? arguments, CancellationToken cancellationToken = default) |
| 63 | { |
| 64 | if (string.IsNullOrWhiteSpace(toolKey)) |
| 65 | return "Error: empty toolKey."; |
| 66 | |
| 67 | await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); |
| 68 | |
| 69 | if (!_toolsByKey.TryGetValue(toolKey, out var routing)) |
| 70 | return $"Error: tool not found: {toolKey}"; |
| 71 | |
| 72 | if (!_clients.TryGetValue(routing.ServerName, out var client)) |
| 73 | return $"Error: MCP server not connected: {routing.ServerName}"; |
| 74 | |
| 75 | var args = arguments is null |
| 76 | ? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase) |
| 77 | : new Dictionary<string, object?>(arguments, StringComparer.OrdinalIgnoreCase); |
| 78 | |
| 79 | // MCP tools expect an object dictionary, values can be string/number/bool/null/arrays/objects. |
| 80 | var result = await client.CallToolAsync(routing.ToolName, args).ConfigureAwait(false); |
| 81 | var text = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text; |
| 82 | if (!string.IsNullOrEmpty(text)) |
| 83 | return text; |
| 84 | |
| 85 | // Fallback: return full content as JSON. |
| 86 | return JsonSerializer.Serialize(result.Content, new JsonSerializerOptions { WriteIndented = false }); |
| 87 | } |
| 88 | |
| 89 | public async ValueTask DisposeAsync() |
| 90 | { |
| 91 | foreach (var kv in _clients) |
| 92 | { |
| 93 | try { await kv.Value.DisposeAsync().ConfigureAwait(false); } |
| 94 | catch { /* ignore */ } |
| 95 | } |
| 96 | _clients.Clear(); |
| 97 | _toolsByKey.Clear(); |
| 98 | } |
| 99 | |
| 100 | private IReadOnlyList<ServerSpec> ParseSpecs() |
| 101 | { |
| 102 | try |
| 103 | { |
| 104 | using var doc = JsonDocument.Parse(_externalMcpServersJson); |
| 105 | if (doc.RootElement.ValueKind != JsonValueKind.Array) |
| 106 | return []; |
| 107 | |
| 108 | var list = new List<ServerSpec>(); |
| 109 | foreach (var el in doc.RootElement.EnumerateArray()) |
| 110 | { |
| 111 | if (el.ValueKind != JsonValueKind.Object) |
| 112 | continue; |
| 113 | |
| 114 | string? name = el.TryGetProperty("name", out var n) ? n.GetString() : null; |
| 115 | string? command = el.TryGetProperty("command", out var c) ? c.GetString() : null; |
| 116 | bool enabled = el.TryGetProperty("enabled", out var en) && en.ValueKind == JsonValueKind.True; |
| 117 | string? toolPrefix = el.TryGetProperty("toolPrefix", out var tp) ? tp.GetString() : null; |
| 118 | |
| 119 | var args = new List<string>(); |
| 120 | if (el.TryGetProperty("arguments", out var a) && a.ValueKind == JsonValueKind.Array) |
| 121 | { |
| 122 | foreach (var arg in a.EnumerateArray()) |
| 123 | { |
| 124 | if (arg.ValueKind == JsonValueKind.String) |
| 125 | args.Add(arg.GetString() ?? ""); |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(command)) |
| 130 | continue; |
| 131 | |
| 132 | list.Add(new ServerSpec( |
| 133 | Name: name, |
| 134 | Command: command, |
| 135 | Arguments: args, |
| 136 | ToolPrefix: string.IsNullOrWhiteSpace(toolPrefix) ? name : toolPrefix, |
| 137 | Enabled: enabled)); |
| 138 | } |
| 139 | return list; |
| 140 | } |
| 141 | catch |
| 142 | { |
| 143 | return []; |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |