Initial commit to new repository
This commit is contained in:
330
src/AxCopilot/Services/McpClientService.cs
Normal file
330
src/AxCopilot/Services/McpClientService.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// MCP (Model Context Protocol) stdio 클라이언트.
|
||||
/// 외부 MCP 서버 프로세스를 실행하고 JSON-RPC 2.0으로 통신합니다.
|
||||
/// </summary>
|
||||
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<McpToolDefinition> _tools = new();
|
||||
private readonly List<McpResourceDefinition> _resources = new();
|
||||
|
||||
public string ServerName => _config.Name;
|
||||
public bool IsConnected => _process is { HasExited: false };
|
||||
public IReadOnlyList<McpToolDefinition> Tools => _tools;
|
||||
public IReadOnlyList<McpResourceDefinition> Resources => _resources;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
public McpClientService(McpServerEntry config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>MCP 서버 프로세스를 시작하고 초기화합니다.</summary>
|
||||
public async Task<bool> 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 = "1.7.2" },
|
||||
}, 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서버에서 사용 가능한 도구 목록을 갱신합니다.</summary>
|
||||
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.TryGetProperty("tools", out var toolsArr))
|
||||
{
|
||||
foreach (var tool in toolsArr.EnumerateArray())
|
||||
{
|
||||
var def = new McpToolDefinition
|
||||
{
|
||||
Name = tool.GetProperty("name").GetString() ?? "",
|
||||
Description = tool.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
|
||||
ServerName = _config.Name,
|
||||
};
|
||||
|
||||
if (tool.TryGetProperty("inputSchema", out var schema) &&
|
||||
schema.TryGetProperty("properties", out var props))
|
||||
{
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
def.Parameters[prop.Name] = new McpParameterDef
|
||||
{
|
||||
Type = prop.Value.TryGetProperty("type", out var t) ? t.GetString() ?? "string" : "string",
|
||||
Description = prop.Value.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "",
|
||||
};
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("required", out var reqArr))
|
||||
{
|
||||
foreach (var req in reqArr.EnumerateArray())
|
||||
{
|
||||
var reqName = req.GetString();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서버에서 사용 가능한 리소스 목록을 갱신합니다.</summary>
|
||||
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.TryGetProperty("resources", out var resourcesArr))
|
||||
{
|
||||
foreach (var resource in resourcesArr.EnumerateArray())
|
||||
{
|
||||
_resources.Add(new McpResourceDefinition
|
||||
{
|
||||
Uri = resource.TryGetProperty("uri", out var uri) ? uri.GetString() ?? "" : "",
|
||||
Name = resource.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
||||
Description = resource.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
|
||||
MimeType = resource.TryGetProperty("mimeType", out var mime) ? mime.GetString() ?? "" : "",
|
||||
ServerName = _config.Name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"MCP '{_config.Name}' 리소스 파싱 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>MCP 도구를 호출합니다.</summary>
|
||||
public async Task<string> CallToolAsync(string toolName, Dictionary<string, object> arguments, CancellationToken ct = default)
|
||||
{
|
||||
var result = await SendRequestAsync("tools/call", new
|
||||
{
|
||||
name = toolName,
|
||||
arguments,
|
||||
}, ct);
|
||||
|
||||
if (result == null) return "[MCP 호출 실패]";
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Value.TryGetProperty("content", out var contentArr))
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in contentArr.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("text", out var text))
|
||||
sb.AppendLine(text.GetString());
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
if (result.Value.TryGetProperty("isError", out var isErr) && isErr.GetBoolean())
|
||||
{
|
||||
return $"[MCP 오류] {result}";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return result?.ToString() ?? "[MCP 빈 응답]";
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<McpResourceDefinition>> ListResourcesAsync(CancellationToken ct = default)
|
||||
{
|
||||
await RefreshResourcesAsync(ct);
|
||||
return _resources.ToList();
|
||||
}
|
||||
|
||||
public async Task<string> ReadResourceAsync(string uri, CancellationToken ct = default)
|
||||
{
|
||||
var result = await SendRequestAsync("resources/read", new { uri }, ct);
|
||||
if (result == null) return "[MCP 리소스 읽기 실패]";
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Value.TryGetProperty("contents", out var contentsArr))
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in contentsArr.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("text", out var text))
|
||||
sb.AppendLine(text.GetString());
|
||||
else if (item.TryGetProperty("uri", out var itemUri))
|
||||
sb.AppendLine($"uri: {itemUri.GetString()}");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return result?.ToString() ?? "[MCP 리소스 빈 응답]";
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> 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.TryGetProperty("id", out _)) continue;
|
||||
|
||||
if (root.TryGetProperty("result", out var result))
|
||||
return result;
|
||||
|
||||
if (root.TryGetProperty("error", out var error))
|
||||
{
|
||||
var msg = error.TryGetProperty("message", out var m) ? m.GetString() : "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 { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user