using System.Diagnostics; using System.IO; using System.Text; using System.Text.Json; using AxCopilot.Models; using AxCopilot.Services.Agent; namespace AxCopilot.Services; /// /// MCP (Model Context Protocol) stdio 클라이언트. /// 외부 MCP 서버 프로세스를 실행하고 JSON-RPC 2.0으로 통신합니다. /// public class McpClientService : IDisposable { private readonly McpServerEntry _config; private Process? _process; private StreamWriter? _writer; private StreamReader? _reader; private int _requestId; internal bool _initialized; private readonly List _tools = new(); private readonly List _resources = new(); public string ServerName => _config.Name; public bool IsConnected => _process is { HasExited: false }; public IReadOnlyList Tools => _tools; public IReadOnlyList Resources => _resources; private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; public McpClientService(McpServerEntry config) { _config = config; } /// MCP 서버 프로세스를 시작하고 초기화합니다. public async Task ConnectAsync(CancellationToken ct = default) { try { if (string.IsNullOrEmpty(_config.Command)) { LogService.Warn($"MCP '{_config.Name}': command가 비어있습니다."); return false; } var psi = new ProcessStartInfo { FileName = _config.Command, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (var arg in _config.Args) psi.ArgumentList.Add(arg); foreach (var (key, value) in _config.Env) psi.Environment[key] = value; _process = Process.Start(psi); if (_process == null) { LogService.Warn($"MCP '{_config.Name}': 프로세스 시작 실패."); return false; } _writer = _process.StandardInput; _reader = _process.StandardOutput; // JSON-RPC: initialize var initResult = await SendRequestAsync("initialize", new { protocolVersion = "2024-11-05", capabilities = new { }, clientInfo = new { name = "AX Copilot", version = "0.7.3" }, }, ct); if (initResult == null) return false; // initialized notification await SendNotificationAsync("notifications/initialized", ct); _initialized = true; // 도구 목록 가져오기 await RefreshToolsAsync(ct); await RefreshResourcesAsync(ct); LogService.Info($"MCP '{_config.Name}': 연결 성공 ({_tools.Count}개 도구)."); return true; } catch (Exception ex) { LogService.Warn($"MCP '{_config.Name}' 연결 실패: {ex.Message}"); return false; } } /// 서버에서 사용 가능한 도구 목록을 갱신합니다. public async Task RefreshToolsAsync(CancellationToken ct = default) { _tools.Clear(); var result = await SendRequestAsync("tools/list", new { }, ct); if (result == null) return; try { if (result.Value.SafeTryGetProperty("tools", out var toolsArr)) { foreach (var tool in toolsArr.EnumerateArray()) { var def = new McpToolDefinition { Name = tool.GetProperty("name").SafeGetString() ?? "", Description = tool.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "", ServerName = _config.Name, }; if (tool.SafeTryGetProperty("inputSchema", out var schema) && schema.SafeTryGetProperty("properties", out var props)) { foreach (var prop in props.EnumerateObject()) { def.Parameters[prop.Name] = new McpParameterDef { Type = prop.Value.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "string" : "string", Description = prop.Value.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "", }; } if (schema.SafeTryGetProperty("required", out var reqArr)) { foreach (var req in reqArr.EnumerateArray()) { var reqName = req.SafeGetString(); if (reqName != null && def.Parameters.TryGetValue(reqName, out var p)) p.Required = true; } } } _tools.Add(def); } } } catch (Exception ex) { LogService.Warn($"MCP '{_config.Name}' 도구 파싱 실패: {ex.Message}"); } } /// 서버에서 사용 가능한 리소스 목록을 갱신합니다. public async Task RefreshResourcesAsync(CancellationToken ct = default) { _resources.Clear(); var result = await SendRequestAsync("resources/list", new { }, ct); if (result == null) return; try { if (result.Value.SafeTryGetProperty("resources", out var resourcesArr)) { foreach (var resource in resourcesArr.EnumerateArray()) { _resources.Add(new McpResourceDefinition { Uri = resource.SafeTryGetProperty("uri", out var uri) ? uri.SafeGetString() ?? "" : "", Name = resource.SafeTryGetProperty("name", out var name) ? name.SafeGetString() ?? "" : "", Description = resource.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "", MimeType = resource.SafeTryGetProperty("mimeType", out var mime) ? mime.SafeGetString() ?? "" : "", ServerName = _config.Name, }); } } } catch (Exception ex) { LogService.Warn($"MCP '{_config.Name}' 리소스 파싱 실패: {ex.Message}"); } } /// MCP 도구를 호출합니다. public async Task CallToolAsync(string toolName, Dictionary arguments, CancellationToken ct = default) { var result = await SendRequestAsync("tools/call", new { name = toolName, arguments, }, ct); if (result == null) return "[MCP 호출 실패]"; try { if (result.Value.SafeTryGetProperty("content", out var contentArr)) { var sb = new StringBuilder(); foreach (var item in contentArr.EnumerateArray()) { if (item.SafeTryGetProperty("text", out var text)) sb.AppendLine(text.SafeGetString()); } return sb.ToString().TrimEnd(); } if (result.Value.SafeTryGetProperty("isError", out var isErr) && isErr.GetBoolean()) { return $"[MCP 오류] {result}"; } } catch { } return result?.ToString() ?? "[MCP 빈 응답]"; } public async Task> ListResourcesAsync(CancellationToken ct = default) { await RefreshResourcesAsync(ct); return _resources.ToList(); } public async Task ReadResourceAsync(string uri, CancellationToken ct = default) { var result = await SendRequestAsync("resources/read", new { uri }, ct); if (result == null) return "[MCP 리소스 읽기 실패]"; try { if (result.Value.SafeTryGetProperty("contents", out var contentsArr)) { var sb = new StringBuilder(); foreach (var item in contentsArr.EnumerateArray()) { if (item.SafeTryGetProperty("text", out var text)) sb.AppendLine(text.SafeGetString()); else if (item.SafeTryGetProperty("uri", out var itemUri)) sb.AppendLine($"uri: {itemUri.SafeGetString()}"); } return sb.ToString().TrimEnd(); } } catch { } return result?.ToString() ?? "[MCP 리소스 빈 응답]"; } private async Task SendRequestAsync(string method, object parameters, CancellationToken ct) { if (_writer == null || _reader == null) return null; var id = Interlocked.Increment(ref _requestId); var request = new { jsonrpc = "2.0", id, method, @params = parameters, }; var json = JsonSerializer.Serialize(request, _jsonOpts); await _writer.WriteLineAsync(json); await _writer.FlushAsync(); // 응답 읽기 (동기식 대기, 타임아웃 10초) using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(10)); while (!cts.Token.IsCancellationRequested) { var line = await _reader.ReadLineAsync(cts.Token); if (string.IsNullOrWhiteSpace(line)) continue; try { var doc = JsonDocument.Parse(line); var root = doc.RootElement; // notification은 건너뛰기 if (!root.SafeTryGetProperty("id", out _)) continue; if (root.SafeTryGetProperty("result", out var result)) return result; if (root.SafeTryGetProperty("error", out var error)) { var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : "Unknown error"; LogService.Warn($"MCP '{_config.Name}' RPC 오류: {msg}"); return null; } } catch { continue; } } return null; } private async Task SendNotificationAsync(string method, CancellationToken ct) { if (_writer == null) return; var notification = new { jsonrpc = "2.0", method }; var json = JsonSerializer.Serialize(notification, _jsonOpts); await _writer.WriteLineAsync(json); await _writer.FlushAsync(); } public void Dispose() { try { _writer?.Dispose(); _reader?.Dispose(); if (_process is { HasExited: false }) { _process.Kill(entireProcessTree: true); _process.Dispose(); } } catch { } } }