218 lines
6.8 KiB
C#
218 lines
6.8 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace ExperionCrawler.Infrastructure.Mcp;
|
|
|
|
/// <summary>
|
|
/// Python FastMCP 서버 (localhost:5001)와 JSON-RPC over HTTP로 통신하는 저수준 클라이언트.
|
|
/// 모델 클래스(McpResponse 등)도 여기서 단일 관리한다.
|
|
/// </summary>
|
|
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<bool> 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<List<McpTool>> 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<string> CallToolAsync(string toolName, Dictionary<string, object> 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<string> RunSqlAsync(string sql) =>
|
|
CallToolAsync("run_sql", new Dictionary<string, object> { ["sql"] = sql });
|
|
|
|
public Task<string> QueryPvHistoryAsync(
|
|
List<string> tagNames, string timeFrom, string timeTo, int limit = 100) =>
|
|
CallToolAsync("query_pv_history", new Dictionary<string, object>
|
|
{
|
|
["tag_names"] = tagNames,
|
|
["time_from"] = timeFrom,
|
|
["time_to"] = timeTo,
|
|
["limit"] = limit
|
|
});
|
|
|
|
public Task<string> GetTagMetadataAsync(string query, int limit = 10) =>
|
|
CallToolAsync("get_tag_metadata", new Dictionary<string, object>
|
|
{
|
|
["query"] = query,
|
|
["limit"] = limit
|
|
});
|
|
|
|
public Task<string> ListDrawingsAsync(string? unitNo = null)
|
|
{
|
|
var args = new Dictionary<string, object>();
|
|
if (!string.IsNullOrEmpty(unitNo))
|
|
args["unit_no"] = unitNo;
|
|
return CallToolAsync("list_drawings", args);
|
|
}
|
|
|
|
public Task<string> QueryWithNlAsync(string question) =>
|
|
CallToolAsync("query_with_nl", new Dictionary<string, object> { ["question"] = question });
|
|
|
|
public Task<string> ExtractPidTagsAsync(string text, string sourceType) =>
|
|
CallToolAsync("extract_pid_tags", new Dictionary<string, object>
|
|
{
|
|
["text"] = text,
|
|
["source_type"] = sourceType
|
|
});
|
|
|
|
public Task<string> MatchPidTagsAsync(IEnumerable<string> pidTags, IEnumerable<string> experionTags) =>
|
|
CallToolAsync("match_pid_tags", new Dictionary<string, object>
|
|
{
|
|
["pid_tags"] = pidTags.ToList(),
|
|
["experion_tags"] = experionTags.ToList()
|
|
});
|
|
|
|
public Task<string> ParsePidDxfAsync(string filepath) =>
|
|
CallToolAsync("parse_pid_dxf", new Dictionary<string, object> { ["filepath"] = filepath });
|
|
|
|
public Task<string> ParsePidPdfAsync(string filepath, bool useOcr = true) =>
|
|
CallToolAsync("parse_pid_pdf", new Dictionary<string, object>
|
|
{
|
|
["filepath"] = filepath,
|
|
["use_ocr"] = useOcr
|
|
});
|
|
|
|
public Task<string> ParsePidDrawingAsync(string filepath) =>
|
|
CallToolAsync("parse_pid_drawing", new Dictionary<string, object> { ["filepath"] = filepath });
|
|
|
|
private async Task<McpResponse?> 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<McpResponse>(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
|