package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.config.McpProperties; 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 Select AI query and read-only prompt diagnostic tools. */ @Service public class McpSseService { private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)"; private static final String GAME_CATALOG_TOOL = "oracle.select_ai.game_catalog_resolve"; private static final String GAME_QUERY_PLAN_TOOL = "oracle.select_ai.game_query_plan"; private final SelectAiService selectAiService; private final ObjectMapper objectMapper; private final BackofficeProperties properties; private final McpProperties mcpProperties; private final QaVectorService qaVectorService; private final GameScopeService gameScopeService; private final GameCatalogVectorService gameCatalogVectorService; public McpSseService( SelectAiService selectAiService, ObjectMapper objectMapper, BackofficeProperties properties, McpProperties mcpProperties, QaVectorService qaVectorService, GameScopeService gameScopeService, GameCatalogVectorService gameCatalogVectorService ) { this.selectAiService = selectAiService; this.objectMapper = objectMapper; this.properties = properties; this.mcpProperties = mcpProperties; this.qaVectorService = qaVectorService; this.gameScopeService = gameScopeService; this.gameCatalogVectorService = gameCatalogVectorService; } 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; } /** Tools registered by this MCP server. */ public List registeredTools() { return List.of( selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView(), gameCatalogView(), gameQueryPlanView()); } 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(toolDefinition(selectAiQueryView())); tools.add(toolDefinition(selectAiShowpromptView())); tools.add(toolDefinition(qaVectorSearchView())); tools.add(toolDefinition(qaVectorStoreView())); tools.add(toolDefinition(fewShotNl2SqlView())); tools.add(toolDefinition(gameScopeView())); tools.add(toolDefinition(gameCatalogView())); tools.add(toolDefinition(gameQueryPlanView())); result.set("tools", tools); return result; } private ObjectNode toolDefinition(McpToolView toolView) { ObjectNode item = objectMapper.createObjectNode(); item.put("name", toolView.name()); item.put("description", toolView.description()); ObjectNode schema = objectMapper.createObjectNode(); schema.put("type", "object"); ObjectNode properties = objectMapper.createObjectNode(); ArrayNode required = objectMapper.createArrayNode(); if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name()) || GAME_CATALOG_TOOL.equals(toolView.name()) || GAME_QUERY_PLAN_TOOL.equals(toolView.name())) { addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000); ObjectNode topK = properties.putObject("topK"); topK.put("type", "integer"); topK.put("description", "반환할 유사 예제 수입니다. 기본값은 3입니다."); topK.put("minimum", 1); topK.put("maximum", 20); topK.put("default", 3); required.add("question"); } else if (qaVectorStoreToolName().equals(toolView.name())) { addStringProperty(properties, "question", "검토된 Select AI 예제가 답한 업무 질문입니다.", 4000); addStringProperty(properties, "answerSql", "검토된 단일 읽기 전용 SELECT/WITH SQL입니다.", 20000); addStringProperty(properties, "answer", "선택 사항인 답변 또는 검토 메모입니다.", 20000); required.add("question"); required.add("answerSql"); } else { addStringProperty(properties, "prompt", promptDescription(), 4000); if (fewShotNl2SqlToolName().equals(toolView.name())) { addStringProperty(properties, "scopeGameKey", "선택 사항입니다. game_scope_resolve가 반환한 SUPPORTED gameKey만 전달하세요.", 128); } required.add("prompt"); } schema.set("properties", properties); schema.set("required", required); schema.put("additionalProperties", false); item.set("inputSchema", schema); return item; } private void addStringProperty(ObjectNode properties, String name, String description, int maxLength) { ObjectNode property = properties.putObject(name); property.put("type", "string"); property.put("description", description); property.put("maxLength", maxLength); } private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { String calledToolName = params.path("name").asText(""); boolean queryTool = toolName().equals(calledToolName); boolean showpromptTool = showpromptToolName().equals(calledToolName); boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName); boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName); boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName); boolean gameScopeTool = gameScopeToolName().equals(calledToolName); boolean gameCatalogTool = GAME_CATALOG_TOOL.equals(calledToolName); boolean gameQueryPlanTool = GAME_QUERY_PLAN_TOOL.equals(calledToolName); if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool && !gameScopeTool && !gameCatalogTool && !gameQueryPlanTool) { throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName); } JsonNode arguments = params.path("arguments"); String token = vpdBearerToken == null ? "" : vpdBearerToken.trim(); if (token.isBlank()) { return tokenAccessDeniedResult(); } JsonNode response; try { if (qaVectorSearchTool) { response = qaVectorSearchResponse( qaVectorService.search(token, arguments.path("question").asText(""), arguments.path("topK").asInt(3))); } else if (qaVectorStoreTool) { response = qaVectorStoreResponse(qaVectorService.store( token, arguments.path("question").asText(""), arguments.path("answerSql").asText(""), arguments.path("answer").isMissingNode() ? null : arguments.path("answer").asText(null) )); } else if (gameScopeTool) { response = gameScopeResponse(gameScopeService.resolve( token, arguments.path("question").asText(""))); } else if (gameCatalogTool) { response = gameCatalogVectorResponse(gameCatalogVectorService.resolve( token, arguments.path("question").asText(""), arguments.path("topK").asInt(5))); } else if (gameQueryPlanTool) { response = gameQueryPlanResponse(gameCatalogVectorService.resolve( token, arguments.path("question").asText(""), arguments.path("topK").asInt(5))); } else { String prompt = arguments.path("prompt").asText(""); response = queryTool ? selectAiService.generateAndExecute(token, prompt) : fewShotNl2SqlTool ? selectAiService.generateAndExecute(token, prompt, arguments.path("scopeGameKey").asText("")) : selectAiService.generatePrompt(token, prompt); } } catch (VpdTokenAccessDeniedException ignored) { return tokenAccessDeniedResult(); } ObjectNode payload = objectMapper.createObjectNode(); payload.put("toolName", calledToolName); payload.put("profile", selectAiProfile()); payload.put("ordsPath", SELECT_AI_TOOL_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 qaVectorSearchResponse(QaVectorService.VectorSearchResult result) { ObjectNode response = objectMapper.createObjectNode(); response.put("status", "QA_VECTOR_SEARCH"); response.put("instruction", "Select AI SQL 생성 전에 few-shot 예제 SQL 후보를 확인합니다."); response.put("question", result.question()); response.put("topK", result.topK()); ArrayNode examples = response.putArray("examples"); for (QaVectorService.VectorExample example : result.examples()) { ObjectNode item = examples.addObject(); item.put("exampleId", example.exampleId()); item.put("question", example.question()); item.put("answerSql", example.answerSql()); if (example.answer() != null) { item.put("answer", example.answer()); } item.put("embeddingModel", example.embeddingModel()); item.put("cosineDistance", example.cosineDistance()); } return response; } private ObjectNode gameCatalogVectorResponse(GameCatalogVectorService.Resolution resolution) { ObjectNode response = objectMapper.createObjectNode(); response.put("scopeType", resolution.scopeType()); response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "CANDIDATES"); ArrayNode items = response.putArray("matchedGames"); for (GameCatalogVectorService.GameCandidate c : resolution.candidates()) { ObjectNode item = items.addObject(); item.put("gameKey", c.gameKey()); item.put("gameId", c.gameId()); item.put("gamePrefix", c.gamePrefix()); item.put("gameName", c.gameName()); item.put("aliases", c.aliases()); item.put("cosineDistance", c.similarity()); } return response; } private ObjectNode qaVectorStoreResponse(QaVectorService.VectorStoreResult stored) { ObjectNode response = objectMapper.createObjectNode(); response.put("status", "QA_VECTOR_STORED"); response.put("instruction", "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장했습니다."); response.put("exampleId", stored.exampleId()); response.put("question", stored.question()); response.put("embeddingModel", stored.embeddingModel()); return response; } /** Converts DB-derived scope facts to a tool response; this tool never executes query SQL. */ private ObjectNode gameScopeResponse(GameScopeService.GameScopeResult result) { ObjectNode response = objectMapper.createObjectNode(); response.put("status", result.status()); response.put("instruction", "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요."); response.put("question", result.question()); ArrayNode scopes = response.putArray("scopes"); for (GameScopeService.GameScope scope : result.scopes()) { ObjectNode item = scopes.addObject(); item.put("gameKey", scope.gameKey()); item.put("displayName", scope.displayName()); item.put("matchedAlias", scope.matchedAlias()); item.put("status", scope.status()); item.put("reasonCode", scope.reasonCode()); item.put("aliasPriority", scope.aliasPriority()); item.put("scopeVersion", scope.scopeVersion()); item.put("nextAction", "SUPPORTED".equals(scope.status()) ? "CALL_FEW_SHOT_NL2SQL" : "REPORT_UNSUPPORTED"); } return response; } 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 McpToolView selectAiQueryView() { String profile = selectAiProfile(); return new McpToolView( toolName(), profile + " 프로파일로 " + toolDescription(), -1L, toolLabel(), SELECT_AI_TOOL_PATH ); } private McpToolView selectAiShowpromptView() { String profile = selectAiProfile(); return new McpToolView( showpromptToolName(), profile + " 프로파일로 " + showpromptToolDescription(), -1L, showpromptToolLabel(), SELECT_AI_TOOL_PATH ); } private McpToolView qaVectorSearchView() { return new McpToolView( qaVectorSearchToolName(), qaVectorSearchToolDescription(), -1L, qaVectorSearchToolLabel(), SELECT_AI_TOOL_PATH); } private McpToolView qaVectorStoreView() { return new McpToolView( qaVectorStoreToolName(), qaVectorStoreToolDescription(), -1L, qaVectorStoreToolLabel(), SELECT_AI_TOOL_PATH); } private McpToolView fewShotNl2SqlView() { return new McpToolView( fewShotNl2SqlToolName(), fewShotNl2SqlToolDescription(), -1L, fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH); } private McpToolView gameScopeView() { return new McpToolView( gameScopeToolName(), gameScopeToolDescription(), -1L, gameScopeToolLabel(), SELECT_AI_TOOL_PATH); } private McpToolView gameCatalogView() { return new McpToolView( GAME_CATALOG_TOOL, "게임 카탈로그에서 질문의 게임 범위를 확인합니다. SQL은 실행하지 않습니다.", -1L, "게임 카탈로그 범위 확인", SELECT_AI_TOOL_PATH); } private McpToolView gameQueryPlanView() { return new McpToolView(GAME_QUERY_PLAN_TOOL, "질문에서 게임 범위와 실행 모드를 판정합니다. SQL은 실행하지 않습니다.", -1L, "게임 질의 계획", SELECT_AI_TOOL_PATH); } private ObjectNode gameQueryPlanResponse(GameCatalogVectorService.Resolution resolution) { ObjectNode response = gameCatalogVectorResponse(resolution); response.put("status", resolution.candidates().isEmpty() ? "NO_MATCH" : "SUPPORTED"); String mode = switch (resolution.scopeType()) { case "SINGLE_GAME" -> "SINGLE"; case "MULTI_GAME" -> "FAN_OUT"; case "ALL_GAMES" -> "GROUPED"; default -> "BLOCKED"; }; response.put("executionMode", mode); return response; } private String selectAiProfile() { BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) { return ""; } return selectAi.profile().trim(); } private String toolName() { return mcpProperties == null ? "oracle.select_ai.data_text2sql" : mcpProperties.resolvedToolName(); } private String toolLabel() { return mcpProperties == null ? "업무 데이터 Text2SQL" : mcpProperties.resolvedToolLabel(); } private String toolDescription() { return mcpProperties == null ? "승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 실행합니다." : mcpProperties.resolvedToolDescription(); } private String promptDescription() { return mcpProperties == null ? "업무 데이터에서 조회할 내용을 자연어로 입력합니다." : mcpProperties.resolvedPromptDescription(); } private String showpromptToolName() { return mcpProperties == null ? "oracle.select_ai.data_showprompt" : mcpProperties.resolvedShowpromptToolName(); } private String showpromptToolLabel() { return mcpProperties == null ? "업무 데이터 SHOWPROMPT" : mcpProperties.resolvedShowpromptToolLabel(); } private String showpromptToolDescription() { return mcpProperties == null ? "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다." : mcpProperties.resolvedShowpromptToolDescription(); } private String qaVectorSearchToolName() { return mcpProperties == null ? "oracle.select_ai.qa_vector_search" : mcpProperties.resolvedQaVectorSearchToolName(); } private String qaVectorSearchToolLabel() { return mcpProperties == null ? "Select AI 예제 SQL 조회" : mcpProperties.resolvedQaVectorSearchToolLabel(); } private String qaVectorSearchToolDescription() { return mcpProperties == null ? "현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다." : mcpProperties.resolvedQaVectorSearchToolDescription(); } private String qaVectorStoreToolName() { return mcpProperties == null ? "oracle.select_ai.qa_vector_store" : mcpProperties.resolvedQaVectorStoreToolName(); } private String qaVectorStoreToolLabel() { return mcpProperties == null ? "Select AI 예제 SQL 저장" : mcpProperties.resolvedQaVectorStoreToolLabel(); } private String qaVectorStoreToolDescription() { return mcpProperties == null ? "검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다." : mcpProperties.resolvedQaVectorStoreToolDescription(); } private String fewShotNl2SqlToolName() { return mcpProperties == null ? "oracle.select_ai.smilegate_fewshot_nl2sql" : mcpProperties.resolvedFewShotNl2SqlToolName(); } private String fewShotNl2SqlToolLabel() { return mcpProperties == null ? "Few-shot NL2SQL 실행" : mcpProperties.resolvedFewShotNl2SqlToolLabel(); } private String fewShotNl2SqlToolDescription() { return mcpProperties == null ? "벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다." : mcpProperties.resolvedFewShotNl2SqlToolDescription(); } private String gameScopeToolName() { return mcpProperties == null ? "oracle.select_ai.game_scope_resolve" : mcpProperties.resolvedGameScopeToolName(); } private String gameScopeToolLabel() { return mcpProperties == null ? "게임 조회 범위 확인" : mcpProperties.resolvedGameScopeToolLabel(); } private String gameScopeToolDescription() { return mcpProperties == null ? "질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다." : mcpProperties.resolvedGameScopeToolDescription(); } private String pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); } catch (Exception e) { return String.valueOf(value); } } }