using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace ExperionCrawler.Infrastructure.Mcp; /// /// Python FastMCP 서버 (localhost:5001)와 JSON-RPC over HTTP로 통신하는 저수준 클라이언트. /// 모델 클래스(McpResponse 등)도 여기서 단일 관리한다. /// public class McpClient { private readonly HttpClient _httpClient; private const string BaseUrl = "http://localhost:5001"; private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; public McpClient(HttpClient? httpClient = null) { _httpClient = httpClient ?? new HttpClient { BaseAddress = new Uri(BaseUrl), Timeout = TimeSpan.FromSeconds(1800) }; } public async Task PingAsync() { try { // FastMCP는 /health 대신 /mcp 엔드포인트를 제공함 // 406은 Accept 헤더 문제이지만, MCP 서버가 실행 중이라는 의미 var response = await _httpClient.GetAsync("/mcp"); return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotAcceptable; } catch { return false; } } public async Task> ListToolsAsync() { var request = new { jsonrpc = "2.0", id = Guid.NewGuid().ToString(), method = "tools/list" }; var response = await SendRequestAsync(request); if (response?.result?.tools == null) return []; return [.. response.result.tools]; } public async Task CallToolAsync(string toolName, Dictionary arguments) { var request = new { jsonrpc = "2.0", id = Guid.NewGuid().ToString(), method = "tools/call", @params = new { name = toolName, arguments = arguments } }; try { var response = await SendRequestAsync(request); var content = response?.result?.content; if (content == null || content.Length == 0) return "호출 결과 없음"; var sb = new StringBuilder(); foreach (var item in content) { if (item.type == "text") sb.AppendLine(item.text); else if (item.type == "image") sb.AppendLine($"[이미지: {item.data ?? "blob"}]"); } return sb.Length > 0 ? sb.ToString().TrimEnd() : "호출 결과 없음"; } catch (Exception ex) { return $"도구 호출 실패: {ex.Message}"; } } public Task RunSqlAsync(string sql) => CallToolAsync("run_sql", new Dictionary { ["sql"] = sql }); public Task QueryPvHistoryAsync( List tagNames, string timeFrom, string timeTo, int limit = 100) => CallToolAsync("query_pv_history", new Dictionary { ["tag_names"] = tagNames, ["time_from"] = timeFrom, ["time_to"] = timeTo, ["limit"] = limit }); public Task GetTagMetadataAsync(string query, int limit = 10) => CallToolAsync("get_tag_metadata", new Dictionary { ["query"] = query, ["limit"] = limit }); public Task ListDrawingsAsync(string? unitNo = null) { var args = new Dictionary(); if (!string.IsNullOrEmpty(unitNo)) args["unit_no"] = unitNo; return CallToolAsync("list_drawings", args); } public Task QueryWithNlAsync(string question) => CallToolAsync("query_with_nl", new Dictionary { ["question"] = question }); public Task ExtractPidTagsAsync(string text, string sourceType) => CallToolAsync("extract_pid_tags", new Dictionary { ["text"] = text, ["source_type"] = sourceType }); public Task MatchPidTagsAsync(IEnumerable pidTags, IEnumerable experionTags) => CallToolAsync("match_pid_tags", new Dictionary { ["pid_tags"] = pidTags.ToList(), ["experion_tags"] = experionTags.ToList() }); public Task ParsePidDxfAsync(string filepath) => CallToolAsync("parse_pid_dxf", new Dictionary { ["filepath"] = filepath }); public Task ParsePidPdfAsync(string filepath, bool useOcr = true) => CallToolAsync("parse_pid_pdf", new Dictionary { ["filepath"] = filepath, ["use_ocr"] = useOcr }); public Task ParsePidDrawingAsync(string filepath) => CallToolAsync("parse_pid_drawing", new Dictionary { ["filepath"] = filepath }); private async Task SendRequestAsync(object request) { var json = JsonSerializer.Serialize(request); var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp") { Content = content }; // MCP 프로토콜: streamable-http 전송에는 application/json Accept 헤더 필요 httpRequest.Headers.Add("Accept", "application/json"); httpRequest.Headers.Add("mcp-protocol-version", "2025-03-26"); var response = await _httpClient.SendAsync(httpRequest); if (!response.IsSuccessStatusCode) return null; var body = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(body, _jsonOptions); } } #region MCP JSON-RPC 모델 public class McpResponse { public string? jsonrpc { get; set; } public string? id { get; set; } public McpErrorBody? error { get; set; } public McpResult? result { get; set; } } public class McpErrorBody { public int? code { get; set; } public string? message { get; set; } public override string ToString() => message ?? "(오류 메시지 없음)"; } public class McpResult { public McpTool[]? tools { get; set; } public McpContentItem[]? content { get; set; } } public class McpTool { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; [JsonPropertyName("inputSchema")] public JsonElement? InputSchema { get; set; } } public class McpContentItem { public string type { get; set; } = string.Empty; public string? text { get; set; } public string? data { get; set; } public string? mimeType { get; set; } } #endregion