Forge
csharpdeeb25a2
1using System.Collections.Concurrent;
2using System.Text.Json;
3using ModelContextProtocol.Client;
4using ModelContextProtocol.Protocol;
5
6namespace CascadeIDE.Services;
7
8/// <summary>
9/// Клиент к внешним MCP-серверам (tool calling) — автономный режим может списокировать и вызывать тулы.
10/// Подключение пока реализовано только через stdio (StdioClientTransport).
11/// </summary>
12public 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
View only · write via MCP/CIDE