From b69abe0f3a0f98209309a7f98421b659a2a3765c Mon Sep 17 00:00:00 2001 From: devmrko Date: Mon, 27 Jul 2026 13:43:59 +0900 Subject: [PATCH] refs #731: expose database game scope MCP --- .env.example | 7 ++ .../vpdbackoffice/config/McpProperties.java | 23 ++++++- .../vpdbackoffice/service/McpSseService.java | 69 +++++++++++++++++-- .../src/main/resources/application.yml | 9 +++ .../service/McpSseServiceTest.java | 59 ++++++++++++++-- 5 files changed, 157 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 7e3f030..54ca6e0 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,10 @@ export BACKOFFICE_SELECT_AI_DB_PASSWORD="" export BACKOFFICE_SELECT_AI_PROFILE="" export BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED="true" export BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K="3" +# Customer-owned DB view: game aliases, approved profile objects, and valid DB objects. +export BACKOFFICE_GAME_SCOPE_ENABLED="false" +export BACKOFFICE_GAME_SCOPE_VIEW="" +export BACKOFFICE_GAME_SCOPE_MAX_SCOPES="8" # 공통 데이터 카탈로그. objects는 key/tableName/objectType/businessName/description JSON 배열입니다. # 배포 환경마다 반드시 실제 소유자와 허용 객체를 지정합니다. @@ -82,6 +86,9 @@ export BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION="현재 질문에 사용 export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME="oracle.select_ai.qa_vector_store" export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL="Select AI 예제 SQL 저장" export BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION="검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다." +export BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME="oracle.select_ai.game_scope_resolve" +export BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL="게임 조회 범위 확인" +export BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION="질문의 게임 별칭을 DB 범위 view로 확인하고, SUPPORTED 결과에만 Few-shot NL2SQL을 호출하도록 안내합니다." # 마스킹 관리 대상. objectName/policyName JSON 배열이며, 비우면 어떤 DB 정책도 관리하지 않습니다. export BACKOFFICE_MASKING_POLICIES='' # 보안 SQL 화면에 노출할 번들 SQL. fileName은 패키지의 sql/adb/ 아래 파일명만 허용됩니다. diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java index 5ae2040..74a0b24 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -20,7 +20,10 @@ public record McpProperties( String qaVectorStoreToolDescription, String fewShotNl2SqlToolName, String fewShotNl2SqlToolLabel, - String fewShotNl2SqlToolDescription + String fewShotNl2SqlToolDescription, + String gameScopeToolName, + String gameScopeToolLabel, + String gameScopeToolDescription ) { private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; @@ -56,6 +59,12 @@ public record McpProperties( private static final String DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION = "벡터 Few-shot 예제를 찾아 prompt에 반영하고, SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. " + "Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다."; + private static final String DEFAULT_GAME_SCOPE_TOOL_NAME = + "oracle.select_ai.game_scope_resolve"; + private static final String DEFAULT_GAME_SCOPE_TOOL_LABEL = "게임 조회 범위 확인"; + private static final String DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION = + "질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, " + + "반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요."; public String resolvedToolName() { return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); @@ -121,6 +130,18 @@ public record McpProperties( return requiredOrDefault(fewShotNl2SqlToolDescription, DEFAULT_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION); } + public String resolvedGameScopeToolName() { + return requiredOrDefault(gameScopeToolName, DEFAULT_GAME_SCOPE_TOOL_NAME); + } + + public String resolvedGameScopeToolLabel() { + return requiredOrDefault(gameScopeToolLabel, DEFAULT_GAME_SCOPE_TOOL_LABEL); + } + + public String resolvedGameScopeToolDescription() { + return requiredOrDefault(gameScopeToolDescription, DEFAULT_GAME_SCOPE_TOOL_DESCRIPTION); + } + private String requiredOrDefault(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); } diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 110cfaa..b2c3a09 100644 --- a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -21,19 +21,22 @@ public class McpSseService { private final BackofficeProperties properties; private final McpProperties mcpProperties; private final QaVectorService qaVectorService; + private final GameScopeService gameScopeService; public McpSseService( SelectAiService selectAiService, ObjectMapper objectMapper, BackofficeProperties properties, McpProperties mcpProperties, - QaVectorService qaVectorService + QaVectorService qaVectorService, + GameScopeService gameScopeService ) { this.selectAiService = selectAiService; this.objectMapper = objectMapper; this.properties = properties; this.mcpProperties = mcpProperties; this.qaVectorService = qaVectorService; + this.gameScopeService = gameScopeService; } public ObjectNode handle(String contextPath, JsonNode request) { @@ -74,7 +77,7 @@ public class McpSseService { public List registeredTools() { return List.of( selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), - qaVectorStoreView(), fewShotNl2SqlView()); + qaVectorStoreView(), fewShotNl2SqlView(), gameScopeView()); } private ObjectNode initializeResult(String contextPath) { @@ -98,6 +101,7 @@ public class McpSseService { tools.add(toolDefinition(qaVectorSearchView())); tools.add(toolDefinition(qaVectorStoreView())); tools.add(toolDefinition(fewShotNl2SqlView())); + tools.add(toolDefinition(gameScopeView())); result.set("tools", tools); return result; } @@ -112,7 +116,7 @@ public class McpSseService { ObjectNode properties = objectMapper.createObjectNode(); ArrayNode required = objectMapper.createArrayNode(); - if (qaVectorSearchToolName().equals(toolView.name())) { + if (qaVectorSearchToolName().equals(toolView.name()) || gameScopeToolName().equals(toolView.name())) { addStringProperty(properties, "question", "few-shot 예제 SQL을 찾을 현재 질문입니다.", 4000); ObjectNode topK = properties.putObject("topK"); topK.put("type", "integer"); @@ -129,6 +133,10 @@ public class McpSseService { 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); @@ -152,7 +160,9 @@ public class McpSseService { boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName); boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName); boolean fewShotNl2SqlTool = fewShotNl2SqlToolName().equals(calledToolName); - if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool) { + boolean gameScopeTool = gameScopeToolName().equals(calledToolName); + if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool && !fewShotNl2SqlTool + && !gameScopeTool) { throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName); } @@ -173,10 +183,16 @@ public class McpSseService { 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 { String prompt = arguments.path("prompt").asText(""); - response = queryTool || fewShotNl2SqlTool + response = queryTool ? selectAiService.generateAndExecute(token, prompt) + : fewShotNl2SqlTool + ? selectAiService.generateAndExecute(token, prompt, + arguments.path("scopeGameKey").asText("")) : selectAiService.generatePrompt(token, prompt); } } catch (VpdTokenAccessDeniedException ignored) { @@ -231,6 +247,28 @@ public class McpSseService { 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"); @@ -287,6 +325,12 @@ public class McpSseService { fewShotNl2SqlToolLabel(), SELECT_AI_TOOL_PATH); } + private McpToolView gameScopeView() { + return new McpToolView( + gameScopeToolName(), gameScopeToolDescription(), -1L, + gameScopeToolLabel(), SELECT_AI_TOOL_PATH); + } + private String selectAiProfile() { BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) { @@ -379,6 +423,21 @@ public class McpSseService { : 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); diff --git a/vpd-backoffice/src/main/resources/application.yml b/vpd-backoffice/src/main/resources/application.yml index f5a7ace..93487ce 100644 --- a/vpd-backoffice/src/main/resources/application.yml +++ b/vpd-backoffice/src/main/resources/application.yml @@ -77,6 +77,12 @@ backoffice: profile: ${BACKOFFICE_SELECT_AI_PROFILE:} few-shot-enabled: ${BACKOFFICE_SELECT_AI_FEW_SHOT_ENABLED:true} few-shot-top-k: ${BACKOFFICE_SELECT_AI_FEW_SHOT_TOP_K:3} + game-scope: + # Customer DB view contract: aliases + profile object list + Oracle objects. + # No customer game or table identifier is embedded in application code. + enabled: ${BACKOFFICE_GAME_SCOPE_ENABLED:false} + view-name: ${BACKOFFICE_GAME_SCOPE_VIEW:} + max-scopes: ${BACKOFFICE_GAME_SCOPE_MAX_SCOPES:8} catalog: owner: ${BACKOFFICE_CATALOG_OWNER:} objects: ${BACKOFFICE_CATALOG_OBJECTS:} @@ -101,6 +107,9 @@ backoffice: few-shot-nl2sql-tool-name: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_NAME:oracle.select_ai.smilegate_fewshot_nl2sql} few-shot-nl2sql-tool-label: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_LABEL:Few-shot NL2SQL 실행} few-shot-nl2sql-tool-description: ${BACKOFFICE_MCP_FEW_SHOT_NL2SQL_TOOL_DESCRIPTION:벡터 Few-shot 예제를 찾아 prompt에 반영하고 SHOWSQL로 생성한 읽기 전용 SQL을 실행합니다. Few-shot 근거, 생성 SQL, 실행 결과를 함께 반환합니다.} + game-scope-tool-name: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_NAME:oracle.select_ai.game_scope_resolve} + game-scope-tool-label: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_LABEL:게임 조회 범위 확인} + game-scope-tool-description: ${BACKOFFICE_MCP_GAME_SCOPE_TOOL_DESCRIPTION:질문에서 언급된 게임 별칭을 DB 게임 범위 view로 확인합니다. 데이터 SQL은 실행하지 않으며, 반환된 SUPPORTED scope에만 Few-shot NL2SQL을 호출하세요.} masking: policies: ${BACKOFFICE_MASKING_POLICIES:} security-sql-scripts: diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index ad0814f..94939ec 100644 --- a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; +import com.cloudhandson.vpdbackoffice.config.GameScopeProperties; import com.cloudhandson.vpdbackoffice.config.McpProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; @@ -13,6 +14,7 @@ class McpSseServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final SelectAiService selectAiService = new CapturingSelectAiService(); private final QaVectorService qaVectorService = new CapturingQaVectorService(); + private final GameScopeService gameScopeService = new CapturingGameScopeService(); private final McpSseService service = new McpSseService( selectAiService, objectMapper, @@ -40,9 +42,13 @@ class McpSseServiceTest { "테스트 예제 SQL을 저장합니다.", "oracle.select_ai.test_fewshot_nl2sql", "테스트 Few-shot NL2SQL", - "테스트 Few-shot 검색 및 실행 도구입니다." + "테스트 Few-shot 검색 및 실행 도구입니다.", + "oracle.select_ai.test_game_scope_resolve", + "테스트 게임 범위 확인", + "테스트 DB 게임 범위 확인 도구입니다." ), - qaVectorService + qaVectorService, + gameScopeService ); @Test @@ -50,7 +56,7 @@ class McpSseServiceTest { ObjectNode response = service.handle("default", request(1, "tools/list")); var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(5); + assertThat(tools).hasSize(6); var selectAi = tools.get(0); assertThat(selectAi.path("name").asText()).isEqualTo("oracle.select_ai.test_data_text2sql"); assertThat(selectAi.path("description").asText()).contains("SGMP_POC_OCI_GPT54MINI"); @@ -90,6 +96,12 @@ class McpSseServiceTest { assertThat(fewShot.path("name").asText()) .isEqualTo("oracle.select_ai.test_fewshot_nl2sql"); assertThat(fewShot.path("description").asText()).contains("Few-shot"); + + var gameScope = tools.get(5); + assertThat(gameScope.path("name").asText()) + .isEqualTo("oracle.select_ai.test_game_scope_resolve"); + assertThat(gameScope.path("inputSchema").path("required")) + .extracting(node -> node.asText()).contains("question"); } @Test @@ -208,6 +220,23 @@ class McpSseServiceTest { .contains("exampleId"); } + @Test + void resolvesGameScopeFromTheDatabaseBackedTool() { + ObjectNode request = request(8, "tools/call"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "oracle.select_ai.test_game_scope_resolve"); + params.putObject("arguments").put("question", "games requested"); + + ObjectNode response = service.handle("default", request, "user-bearer"); + + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + String payload = response.path("result").path("content").get(0).path("text").asText(); + assertThat(payload) + .contains("RESOLVED") + .contains("CALL_FEW_SHOT_NL2SQL") + .contains("REPORT_UNSUPPORTED"); + } + private ObjectNode request(int id, String method) { ObjectNode request = objectMapper.createObjectNode(); request.put("jsonrpc", "2.0"); @@ -223,7 +252,7 @@ class McpSseServiceTest { private boolean showpromptCalled; private CapturingSelectAiService() { - super(null, null, null, new ObjectMapper(), null); + super(null, null, null, new ObjectMapper(), null, null); } @Override @@ -240,6 +269,11 @@ class McpSseServiceTest { return response; } + @Override + public JsonNode generateAndExecute(String bearerToken, String prompt, String scopeGameKey) { + return generateAndExecute(bearerToken, prompt); + } + @Override public JsonNode generatePrompt(String bearerToken, String prompt) { this.bearerToken = bearerToken; @@ -282,4 +316,21 @@ class McpSseServiceTest { return new VectorStoreResult(77L, question, "cohere.embed-v4.0"); } } + + private static final class CapturingGameScopeService extends GameScopeService { + + private CapturingGameScopeService() { + super(null, new GameScopeProperties(false, "", 8), null, null); + } + + @Override + public GameScopeResult resolve(String bearerToken, String question) { + return new GameScopeResult(question, "RESOLVED", java.util.List.of( + new GameScope("SUPPORTED_GAME", "Supported game", "Supported", "SUPPORTED", + "APPROVED_OBJECT_AVAILABLE", 100, "100"), + new GameScope("UNSUPPORTED_GAME", "Unsupported game", "Unsupported", "UNSUPPORTED", + "OBJECT_LIST_NOT_AVAILABLE", 100, "100") + )); + } + } }