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