package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; /** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */ @Service public class McpSseService { private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd"; private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query"; private static final String SELECT_AI_VPD_QUERY_PROFILE = "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1"; private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView( SELECT_AI_VPD_QUERY_TOOL, "GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.", -1L, "KB Select AI 행 접근 자연어 조회", SELECT_AI_VPD_QUERY_PATH ); private final SelectAiAgentOrdsService selectAiAgentOrdsService; private final ObjectMapper objectMapper; public McpSseService( SelectAiAgentOrdsService selectAiAgentOrdsService, ObjectMapper objectMapper ) { this.selectAiAgentOrdsService = selectAiAgentOrdsService; this.objectMapper = objectMapper; } public ObjectNode handle(String contextPath, JsonNode request) { return handle(contextPath, request, ""); } /** * The HTTP bearer token is the business-user subject token; no separate MCP token is used. */ public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) { ObjectNode response = objectMapper.createObjectNode(); response.put("jsonrpc", "2.0"); if (request != null && request.has("id")) { response.set("id", request.get("id")); } String method = request == null || !request.hasNonNull("method") ? "" : request.get("method").asText(); JsonNode parameters = request == null ? objectMapper.createObjectNode() : request.path("params"); try { response.set("result", switch (method) { case "initialize" -> initializeResult(contextPath); case "notifications/initialized" -> objectMapper.createObjectNode(); case "tools/list" -> toolsListResult(); case "tools/call" -> toolsCallResult(parameters, vpdBearerToken); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); }); } catch (Exception e) { response.remove("result"); ObjectNode error = objectMapper.createObjectNode(); error.put("code", -32000); error.put("message", e.getMessage()); response.set("error", error); } return response; } /** The only tool registered by this MCP server. */ public List registeredTools() { return List.of(SELECT_AI_VPD_QUERY_VIEW); } private ObjectNode initializeResult(String contextPath) { ObjectNode result = objectMapper.createObjectNode(); result.put("protocolVersion", "2024-11-05"); ObjectNode serverInfo = objectMapper.createObjectNode(); serverInfo.put("name", "vpd-ords-backoffice-" + contextPath); serverInfo.put("version", "0.1.0"); result.set("serverInfo", serverInfo); ObjectNode capabilities = objectMapper.createObjectNode(); capabilities.set("tools", objectMapper.createObjectNode()); result.set("capabilities", capabilities); return result; } private ObjectNode toolsListResult() { ObjectNode result = objectMapper.createObjectNode(); ArrayNode tools = objectMapper.createArrayNode(); tools.add(selectAiVpdQueryTool()); result.set("tools", tools); return result; } private ObjectNode selectAiVpdQueryTool() { ObjectNode item = objectMapper.createObjectNode(); item.put("name", SELECT_AI_VPD_QUERY_TOOL); item.put("description", SELECT_AI_VPD_QUERY_VIEW.description()); ObjectNode schema = objectMapper.createObjectNode(); schema.put("type", "object"); ObjectNode properties = objectMapper.createObjectNode(); ObjectNode prompt = objectMapper.createObjectNode(); prompt.put("type", "string"); prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다."); prompt.put("maxLength", 4000); properties.set("prompt", prompt); ObjectNode limit = objectMapper.createObjectNode(); limit.put("type", "integer"); limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다."); limit.put("minimum", 1); limit.put("maximum", 100); properties.set("limit", limit); schema.set("properties", properties); ArrayNode required = objectMapper.createArrayNode(); required.add("prompt"); schema.set("required", required); schema.put("additionalProperties", false); item.set("inputSchema", schema); return item; } private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { String toolName = params.path("name").asText(""); if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) { throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); } JsonNode arguments = params.path("arguments"); String token = vpdBearerToken == null ? "" : vpdBearerToken.trim(); if (token.isBlank()) { return tokenAccessDeniedResult(); } JsonNode response; try { response = selectAiAgentOrdsService.run( token, arguments.path("prompt").asText(""), normalizeLimit(arguments.path("limit").asInt(50)) ); } catch (VpdTokenAccessDeniedException ignored) { return tokenAccessDeniedResult(); } ObjectNode payload = objectMapper.createObjectNode(); payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL); payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE); payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); payload.set("response", response); ObjectNode result = objectMapper.createObjectNode(); ArrayNode content = objectMapper.createArrayNode(); ObjectNode text = objectMapper.createObjectNode(); text.put("type", "text"); text.put("text", pretty(payload)); content.add(text); result.set("content", content); result.put("isError", false); return result; } private ObjectNode tokenAccessDeniedResult() { ObjectNode payload = objectMapper.createObjectNode(); payload.put("status", "VPD_TOKEN_DENIED"); payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다."); ObjectNode result = objectMapper.createObjectNode(); ArrayNode content = objectMapper.createArrayNode(); ObjectNode text = objectMapper.createObjectNode(); text.put("type", "text"); text.put("text", pretty(payload)); content.add(text); result.set("content", content); result.put("isError", true); return result; } private int normalizeLimit(int limit) { if (limit < 1) { return 50; } return Math.min(limit, 100); } private String pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); } catch (Exception e) { return String.valueOf(value); } } }