feat: MCP 클라이언트 통합 및 TextToSqlController MCP 엔드포인트 추가

- IMcpService 인터페이스 및 McpClient/McpService 구현 추가
  - McpClient: Python MCP 서버(localhost:5001)와 JSON-RPC over HTTP 통신
  - McpService: McpClient 위임 래퍼, IMcpService 구현
  - [JsonPropertyName], PropertyNameCaseInsensitive 적용으로 JSON 역직렬화 수정
- TextToSqlController에 MCP 엔드포인트 5개 추가
  - GET  /api/text-to-sql/tools
  - POST /api/text-to-sql/execute-mcp
  - POST /api/text-to-sql/query-history
  - GET  /api/text-to-sql/tags/search
  - GET  /api/text-to-sql/drawings
- HistoryQueryRequestDto 추가 (TextToSqlDtos.cs)
- QueryHistoryWithIntervalAsync 올바른 메서드 호출로 수정 (IExperionDbService)
- Program.cs: McpClient 싱글톤 등록, AddHttpClient 잘못된 등록 수정
- 빌드 에러 0건, 경고 0건

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-04-28 01:05:09 +09:00
parent 787d859fb5
commit d8266a2d55
6 changed files with 558 additions and 19 deletions

View File

@@ -31,6 +31,15 @@ public class SqlQueryResultDto
public int TotalCount { get; set; }
}
/// <summary>MCP query_pv_history 엔드포인트 요청 DTO</summary>
public class HistoryQueryRequestDto
{
public List<string>? TagNames { get; set; }
public string? From { get; set; }
public string? To { get; set; }
public int? Limit { get; set; }
}
public class TimeSeriesAnalysisDto
{
public bool Success { get; set; }

View File

@@ -0,0 +1,65 @@
namespace ExperionCrawler.Core.Application.Interfaces;
/// <summary>
/// MCP (Model Context Protocol) 서비스 인터페이스
/// Python MCP 서버와의 통신 담당
/// </summary>
public interface IMcpService
{
/// <summary>
/// MCP 서버 상태 확인
/// </summary>
Task<bool> PingAsync();
/// <summary>
/// 도구(tool) 목록 조회
/// </summary>
Task<List<McpToolDto>> ListToolsAsync();
/// <summary>
/// run_sql 도구 호출 - 안전한 SQL 실행
/// </summary>
Task<McpQueryResult> RunSqlAsync(string sql);
/// <summary>
/// query_pv_history 도구 호출 - 과거 값 히스토리 조회
/// </summary>
Task<McpQueryResult> QueryPvHistoryAsync(
List<string> tagNames,
string timeFrom,
string timeTo,
int limit = 100);
/// <summary>
/// get_tag_metadata 도구 호출 - 태그 메타데이터 검색
/// </summary>
Task<McpQueryResult> GetTagMetadataAsync(string query, int limit = 10);
/// <summary>
/// list_drawings 도구 호출 - 도면 목록 조회
/// </summary>
Task<McpQueryResult> ListDrawingsAsync(string? unitNo = null);
}
#region DTOs
/// <summary>
/// MCP 도구 DTO
/// </summary>
public class McpToolDto
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}
/// <summary>
/// MCP 쿼리 결과 DTO
/// </summary>
public class McpQueryResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? Data { get; set; }
}
#endregion

View File

@@ -0,0 +1,185 @@
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(30)
};
}
public async Task<bool> PingAsync()
{
try
{
var response = await _httpClient.GetAsync("/health");
return response.IsSuccessStatusCode;
}
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);
}
private async Task<McpResponse?> SendRequestAsync(object request)
{
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
content.Headers.Add("Accept", "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp")
{
Content = content
};
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

View File

@@ -0,0 +1,79 @@
using ExperionCrawler.Core.Application.Interfaces;
namespace ExperionCrawler.Infrastructure.Mcp;
/// <summary>
/// IMcpService 구현체 — McpClient를 통해 Python MCP 서버와 통신한다.
/// 모든 HTTP/JSON 로직은 McpClient에 위임하고,
/// 이 클래스는 McpQueryResult 래핑만 담당한다.
/// </summary>
public class McpService : IMcpService
{
private readonly McpClient _client;
public McpService(McpClient client)
{
_client = client;
}
public Task<bool> PingAsync() => _client.PingAsync();
public async Task<List<McpToolDto>> ListToolsAsync()
{
var tools = await _client.ListToolsAsync();
return tools.Select(t => new McpToolDto { Name = t.Name, Description = t.Description }).ToList();
}
public async Task<McpQueryResult> RunSqlAsync(string sql)
{
try
{
var data = await _client.RunSqlAsync(sql);
return new McpQueryResult { Success = true, Data = data };
}
catch (Exception ex)
{
return new McpQueryResult { Success = false, Error = ex.Message };
}
}
public async Task<McpQueryResult> QueryPvHistoryAsync(
List<string> tagNames, string timeFrom, string timeTo, int limit = 100)
{
try
{
var data = await _client.QueryPvHistoryAsync(tagNames, timeFrom, timeTo, limit);
return new McpQueryResult { Success = true, Data = data };
}
catch (Exception ex)
{
return new McpQueryResult { Success = false, Error = ex.Message };
}
}
public async Task<McpQueryResult> GetTagMetadataAsync(string query, int limit = 10)
{
try
{
var data = await _client.GetTagMetadataAsync(query, limit);
return new McpQueryResult { Success = true, Data = data };
}
catch (Exception ex)
{
return new McpQueryResult { Success = false, Error = ex.Message };
}
}
public async Task<McpQueryResult> ListDrawingsAsync(string? unitNo = null)
{
try
{
var data = await _client.ListDrawingsAsync(unitNo);
return new McpQueryResult { Success = true, Data = data };
}
catch (Exception ex)
{
return new McpQueryResult { Success = false, Error = ex.Message };
}
}
}

View File

@@ -1,5 +1,6 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Infrastructure.Mcp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -7,24 +8,28 @@ namespace ExperionCrawler.Web.Controllers;
/// <summary>
/// Text-to-SQL API 컨트롤러
/// 자연어 질의를 SQL로 변환하고 시계열 데이터를 조회합니다.
/// 자연어 질의를 파싱하고 시계열 데이터를 조회합니다.
/// MCP (Model Context Protocol) 통합을 위한 엔드포인트를 제공합니다.
/// </summary>
[ApiController]
[Route("api/text-to-sql")]
public class TextToSqlController : ControllerBase
{
private readonly ITextToSqlService _textToSqlService;
private readonly IExperionDbService _experionDbService;
private readonly IExperionDbService _dbService;
private readonly IMcpService _mcpService;
private readonly ILogger<TextToSqlController> _logger;
public TextToSqlController(
ITextToSqlService textToSqlService,
IExperionDbService experionDbService,
IExperionDbService dbService,
IMcpService mcpService,
ILogger<TextToSqlController> logger)
{
_textToSqlService = textToSqlService;
_experionDbService = experionDbService;
_logger = logger;
_textToSqlService = textToSqlService;
_dbService = dbService;
_mcpService = mcpService;
_logger = logger;
}
/// <summary>
@@ -45,19 +50,208 @@ public class TextToSqlController : ControllerBase
}
/// <summary>
/// SQL 쿼리 실행 및 결과 반환
/// MCP 도구 목록 조회
/// </summary>
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] SqlQueryDto dto)
[HttpGet("tools")]
public async Task<IActionResult> ListTools()
{
var result = await _textToSqlService.ExecuteQueryAsync(dto.Sql, dto.Limit);
return Ok(new {
success = result.Success,
error = result.Error,
columns = result.Columns,
rows = result.Rows,
totalCount = result.TotalCount
});
try
{
var tools = await _mcpService.ListToolsAsync();
return Ok(new { success = true, tools });
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 도구 목록 조회 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP run_sql 도구 호출 - SQL 실행
/// Text-to-SQL 엔진으로 생성된 SQL을 안전하게 실행
/// </summary>
[HttpPost("execute-mcp")]
public async Task<IActionResult> ExecuteFromMcp([FromBody] SqlQueryDto dto)
{
if (string.IsNullOrWhiteSpace(dto.Sql))
{
return BadRequest(new { success = false, error = "SQL이 비어있음" });
}
try
{
// MCP run_sql 도구 호출
var result = await _mcpService.RunSqlAsync(dto.Sql);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
// JSON 결과 반환 (쿼리 결과)
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new { success = true, data = jsonData });
}
catch
{
return Ok(new { success = true, data = result.Data });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] MCP 실행 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP query_pv_history 도구 호출 - 과거 값 히스토리 조회
/// </summary>
[HttpPost("query-history")]
public async Task<IActionResult> QueryHistory([FromBody] HistoryQueryRequestDto dto)
{
try
{
var tagNames = dto.TagNames ?? [];
var timeFrom = dto.From ?? DateTime.UtcNow.AddDays(-1).ToString("o");
var timeTo = dto.To ?? DateTime.UtcNow.ToString("o");
var limit = dto.Limit ?? 100;
var result = await _mcpService.QueryPvHistoryAsync(
tagNames,
timeFrom,
timeTo,
limit
);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new
{
success = true,
data = jsonData
});
}
catch
{
return Ok(new
{
success = true,
data = result.Data
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] History 쿼리 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP get_tag_metadata 도구 호출 - 태그 메타데이터 검색
/// </summary>
[HttpGet("tags/search")]
public async Task<IActionResult> SearchTags([FromQuery] string query, [FromQuery] int? limit)
{
try
{
var tagLimit = limit ?? 10;
var result = await _mcpService.GetTagMetadataAsync(query, tagLimit);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new
{
success = true,
data = jsonData
});
}
catch
{
return Ok(new
{
success = true,
data = result.Data
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 태그 검색 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
/// MCP list_drawings 도구 호출 - 도면 목록 조회
/// </summary>
[HttpGet("drawings")]
public async Task<IActionResult> ListDrawings([FromQuery] string? unitNo)
{
try
{
var result = await _mcpService.ListDrawingsAsync(unitNo);
if (!result.Success)
{
return Ok(new
{
success = false,
error = result.Error
});
}
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data);
return Ok(new
{
success = true,
data = jsonData
});
}
catch
{
return Ok(new
{
success = true,
data = result.Data
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[TextToSql] 도면 목록 조회 실패");
return Ok(new { success = false, error = ex.Message });
}
}
/// <summary>
@@ -112,8 +306,8 @@ public class TextToSqlController : ControllerBase
dto.Interval,
dto.Limit);
var result = await _experionDbService.QueryHistoryWithIntervalAsync(request);
var result = await _dbService.QueryHistoryWithIntervalAsync(request);
var response = new
{
success = true,

View File

@@ -3,6 +3,7 @@ using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Infrastructure.Certificates;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using ExperionCrawler.Infrastructure.Mcp;
using ExperionCrawler.Infrastructure.OpcUa;
using Microsoft.EntityFrameworkCore;
@@ -61,6 +62,12 @@ builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService<ExperionHistoryService>();
// ── MCP Service ───────────────────────────────────────────────────────────────
// Python MCP 서버 (localhost:5001)와 통신
// McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임)
builder.Services.AddSingleton<McpClient>();
builder.Services.AddSingleton<IMcpService, McpService>();
// ── OPC UA Server BackgroundService ──────────────────────────────────────────
builder.Services.AddSingleton<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(