diff --git a/docs/design/726-showprompt-diagnostic-mcp/README.md b/docs/design/726-showprompt-diagnostic-mcp/README.md new file mode 100644 index 0000000..8a66a66 --- /dev/null +++ b/docs/design/726-showprompt-diagnostic-mcp/README.md @@ -0,0 +1,52 @@ +# Redmine #726 · Select AI SHOWPROMPT 진단 MCP + +## 목표 + +기존 Text2SQL 생성·실행 tool과 분리된 읽기 전용 SHOWPROMPT 진단 tool을 +MCP `tools/list`에 추가한다. 일반 질문 처리 Agent는 포털 allowlist를 통해 +기존 tool만 사용하고, FAIL 개선 제안 버튼만 진단 tool을 직접 호출한다. + +## 외부 설정 + +- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME` +- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL` +- `BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION` + +기본값은 제품 중립적인 `oracle.select_ai.data_showprompt`와 업무 데이터 +표현을 사용한다. 스마일게이트 운영값은 외부 env에서 고객 전용 이름으로 +설정한다. + +## 동작 + +1. HTTP Bearer Token을 기존 업무 사용자 토큰으로 검증한다. +2. prompt를 기존 4,000자 제한으로 검증한다. +3. schema-owned Select AI 연결에서 + `DBMS_CLOUD_AI.GENERATE(prompt, profile, 'showprompt')`를 호출한다. +4. SQL을 실행하지 않고 다음 JSON을 반환한다. + - `status=SHOWPROMPT` + - `profile` + - `selectAiPrompt` + +## 변경 함수 + +| 파일/함수 | 변경 | +|---|---| +| `McpProperties` | SHOWPROMPT tool 이름·label·description 외부 설정 | +| `SelectAiService.generatePrompt` | 인증·prompt 검증 후 SHOWPROMPT 반환 | +| `SelectAiService.generate` | `showsql/showprompt` action을 bind하는 공통 생성 함수 | +| `McpSseService.toolsListResult` | Text2SQL과 SHOWPROMPT 두 tool 등록 | +| `McpSseService.toolsCallResult` | exact tool name에 따라 query/diagnostic 분기 | + +## 안전 조건 + +- SHOWPROMPT tool은 생성 SQL을 실행하지 않는다. +- 임의 action 인자를 사용자에게 받지 않는다. +- 응답에 Bearer Token이나 DB 연결 정보를 포함하지 않는다. +- 기존 Text2SQL tool 이름과 계약은 유지한다. + +## 테스트 + +- tools/list에 두 tool과 각 prompt schema가 존재한다. +- Text2SQL 호출은 기존 generate-and-execute 경로를 유지한다. +- SHOWPROMPT 호출은 generatePrompt만 실행한다. +- 누락/비활성 Bearer Token은 기존과 동일하게 거절한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java index f052933..f915711 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/McpProperties.java @@ -8,7 +8,10 @@ public record McpProperties( String toolName, String toolLabel, String toolDescription, - String promptDescription + String promptDescription, + String showpromptToolName, + String showpromptToolLabel, + String showpromptToolDescription ) { private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql"; @@ -18,6 +21,13 @@ public record McpProperties( + "생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다."; private static final String DEFAULT_PROMPT_DESCRIPTION = "업무 데이터에서 조회할 내용을 자연어로 입력합니다."; + private static final String DEFAULT_SHOWPROMPT_TOOL_NAME = + "oracle.select_ai.data_showprompt"; + private static final String DEFAULT_SHOWPROMPT_TOOL_LABEL = + "업무 데이터 SHOWPROMPT"; + private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION = + "Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. " + + "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다."; public String resolvedToolName() { return requiredOrDefault(toolName, DEFAULT_TOOL_NAME); @@ -35,6 +45,18 @@ public record McpProperties( return requiredOrDefault(promptDescription, DEFAULT_PROMPT_DESCRIPTION); } + public String resolvedShowpromptToolName() { + return requiredOrDefault(showpromptToolName, DEFAULT_SHOWPROMPT_TOOL_NAME); + } + + public String resolvedShowpromptToolLabel() { + return requiredOrDefault(showpromptToolLabel, DEFAULT_SHOWPROMPT_TOOL_LABEL); + } + + public String resolvedShowpromptToolDescription() { + return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION); + } + private String requiredOrDefault(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 0416c66..4578a69 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -10,11 +10,11 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; -/** MCP boundary exposing a configured Select AI generation and read-only execution tool. */ +/** MCP boundary exposing Select AI query and read-only prompt diagnostic tools. */ @Service public class McpSseService { - private static final String SELECT_AI_VPD_QUERY_PATH = "/mcp (tools/call)"; + private static final String SELECT_AI_TOOL_PATH = "/mcp (tools/call)"; private final SelectAiService selectAiService; private final ObjectMapper objectMapper; @@ -67,9 +67,9 @@ public class McpSseService { return response; } - /** The only tool registered by this MCP server. */ + /** Tools registered by this MCP server. */ public List registeredTools() { - return List.of(selectAiVpdQueryView()); + return List.of(selectAiQueryView(), selectAiShowpromptView()); } private ObjectNode initializeResult(String contextPath) { @@ -88,15 +88,16 @@ public class McpSseService { private ObjectNode toolsListResult() { ObjectNode result = objectMapper.createObjectNode(); ArrayNode tools = objectMapper.createArrayNode(); - tools.add(selectAiVpdQueryTool()); + tools.add(toolDefinition(selectAiQueryView())); + tools.add(toolDefinition(selectAiShowpromptView())); result.set("tools", tools); return result; } - private ObjectNode selectAiVpdQueryTool() { + private ObjectNode toolDefinition(McpToolView toolView) { ObjectNode item = objectMapper.createObjectNode(); - item.put("name", toolName()); - item.put("description", selectAiVpdQueryView().description()); + item.put("name", toolView.name()); + item.put("description", toolView.description()); ObjectNode schema = objectMapper.createObjectNode(); schema.put("type", "object"); @@ -118,9 +119,11 @@ public class McpSseService { } private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) { - String toolName = params.path("name").asText(""); - if (!toolName().equals(toolName)) { - throw new AppException("등록되지 않은 MCP tool입니다: " + toolName); + String calledToolName = params.path("name").asText(""); + boolean queryTool = toolName().equals(calledToolName); + boolean showpromptTool = showpromptToolName().equals(calledToolName); + if (!queryTool && !showpromptTool) { + throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName); } JsonNode arguments = params.path("arguments"); @@ -130,15 +133,18 @@ public class McpSseService { } JsonNode response; try { - response = selectAiService.generateAndExecute(token, arguments.path("prompt").asText("")); + String prompt = arguments.path("prompt").asText(""); + response = queryTool + ? selectAiService.generateAndExecute(token, prompt) + : selectAiService.generatePrompt(token, prompt); } catch (VpdTokenAccessDeniedException ignored) { return tokenAccessDeniedResult(); } ObjectNode payload = objectMapper.createObjectNode(); - payload.put("toolName", toolName()); + payload.put("toolName", calledToolName); payload.put("profile", selectAiProfile()); - payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH); + payload.put("ordsPath", SELECT_AI_TOOL_PATH); payload.set("response", response); ObjectNode result = objectMapper.createObjectNode(); @@ -168,14 +174,25 @@ public class McpSseService { return result; } - private McpToolView selectAiVpdQueryView() { + private McpToolView selectAiQueryView() { String profile = selectAiProfile(); return new McpToolView( toolName(), profile + " 프로파일로 " + toolDescription(), -1L, toolLabel(), - SELECT_AI_VPD_QUERY_PATH + SELECT_AI_TOOL_PATH + ); + } + + private McpToolView selectAiShowpromptView() { + String profile = selectAiProfile(); + return new McpToolView( + showpromptToolName(), + profile + " 프로파일로 " + showpromptToolDescription(), + -1L, + showpromptToolLabel(), + SELECT_AI_TOOL_PATH ); } @@ -205,6 +222,24 @@ public class McpSseService { 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 pretty(Object value) { try { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index 2d166a0..9544f02 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -55,14 +55,9 @@ public class SelectAiService { public JsonNode generateAndExecute(String bearerToken, String prompt) { requireActiveToken(bearerToken); String normalizedPrompt = requiredPrompt(prompt); - BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); - if (selectAi == null || !selectAi.configured()) { - throw new AppException("Select AI 연결 설정이 필요합니다. " - + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " - + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요."); - } + BackofficeProperties.SelectAi selectAi = requiredSelectAi(); - String generatedSql = generate(selectAi, normalizedPrompt); + String generatedSql = generate(selectAi, normalizedPrompt, "showsql"); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly(selectAi, normalizedSql); ObjectNode response = objectMapper.createObjectNode(); @@ -79,6 +74,30 @@ public class SelectAiService { return response; } + /** Returns the prompt Select AI assembled for SQL generation without executing generated SQL. */ + public JsonNode generatePrompt(String bearerToken, String prompt) { + requireActiveToken(bearerToken); + String normalizedPrompt = requiredPrompt(prompt); + BackofficeProperties.SelectAi selectAi = requiredSelectAi(); + String selectAiPrompt = generate(selectAi, normalizedPrompt, "showprompt"); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", "SHOWPROMPT"); + response.put("profile", selectAi.profile()); + response.put("selectAiPrompt", selectAiPrompt); + return response; + } + + private BackofficeProperties.SelectAi requiredSelectAi() { + BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); + if (selectAi == null || !selectAi.configured()) { + throw new AppException("Select AI 연결 설정이 필요합니다. " + + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + + "BACKOFFICE_SELECT_AI_DB_PASSWORD를 확인하세요."); + } + return selectAi; + } + private void requireActiveToken(String bearerToken) { if (bearerToken == null || bearerToken.isBlank()) { throw new VpdTokenAccessDeniedException(); @@ -101,23 +120,29 @@ public class SelectAiService { return normalized; } - private String generate(BackofficeProperties.SelectAi selectAi, String prompt) { - String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, 'showsql') FROM dual"; + private String generate( + BackofficeProperties.SelectAi selectAi, + String prompt, + String action + ) { + String sql = "SELECT DBMS_CLOUD_AI.GENERATE(?, ?, ?) FROM dual"; try (Connection connection = DriverManager.getConnection( selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, prompt); statement.setString(2, selectAi.profile()); + statement.setString(3, action); try (ResultSet resultSet = statement.executeQuery()) { if (!resultSet.next() || resultSet.getString(1) == null) { - throw new AppException("Select AI가 생성 SQL을 반환하지 않았습니다."); + throw new AppException("Select AI가 " + action.toUpperCase() + " 결과를 반환하지 않았습니다."); } return resultSet.getString(1); } } catch (AppException exception) { throw exception; } catch (Exception exception) { - throw new AppException("Select AI SHOWSQL 생성 실패: " + exception.getMessage()); + throw new AppException("Select AI " + action.toUpperCase() + " 생성 실패: " + + exception.getMessage()); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf1d191..7ee7b44 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -87,6 +87,9 @@ backoffice: tool-label: ${BACKOFFICE_MCP_TOOL_LABEL:업무 데이터 Text2SQL} tool-description: ${BACKOFFICE_MCP_TOOL_DESCRIPTION:승인된 업무 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.} prompt-description: ${BACKOFFICE_MCP_PROMPT_DESCRIPTION:업무 데이터에서 조회할 내용을 자연어로 입력합니다.} + showprompt-tool-name: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_NAME:oracle.select_ai.data_showprompt} + showprompt-tool-label: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_LABEL:업무 데이터 SHOWPROMPT} + showprompt-tool-description: ${BACKOFFICE_MCP_SHOWPROMPT_TOOL_DESCRIPTION:Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. 생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.} masking: policies: ${BACKOFFICE_MASKING_POLICIES:} security-sql-scripts: diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 873a0ee..9d084e4 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -26,16 +26,19 @@ class McpSseServiceTest { "oracle.select_ai.test_data_text2sql", "테스트 데이터 Text2SQL", "테스트 데이터용 읽기 전용 SQL을 생성하고 실행합니다.", - "테스트 데이터의 조회 내용을 입력합니다." + "테스트 데이터의 조회 내용을 입력합니다.", + "oracle.select_ai.test_data_showprompt", + "테스트 데이터 SHOWPROMPT", + "테스트 데이터의 Select AI prompt를 조회합니다." ) ); @Test - void listsOnlyVpdSelectAiToolWithPromptInput() { + void listsQueryAndShowpromptToolsWithPromptInput() { ObjectNode response = service.handle("default", request(1, "tools/list")); var tools = response.path("result").path("tools"); - assertThat(tools).hasSize(1); + assertThat(tools).hasSize(2); 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"); @@ -45,6 +48,36 @@ class McpSseServiceTest { assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isFalse(); assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse(); + + var showprompt = tools.get(1); + assertThat(showprompt.path("name").asText()) + .isEqualTo("oracle.select_ai.test_data_showprompt"); + assertThat(showprompt.path("description").asText()) + .contains("SGMP_POC_OCI_GPT54MINI") + .contains("Select AI prompt"); + assertThat(showprompt.path("inputSchema").path("required")) + .extracting(node -> node.asText()) + .contains("prompt"); + } + + @Test + void callsShowpromptWithoutExecutingGeneratedSql() { + ObjectNode request = request(4, "tools/call"); + ObjectNode params = (ObjectNode) request.putObject("params"); + params.put("name", "oracle.select_ai.test_data_showprompt"); + params.putObject("arguments").put("prompt", "버블리즈 유저 마스터 조회해줘."); + + ObjectNode response = service.handle("default", request, "user-bearer"); + + CapturingSelectAiService agentService = (CapturingSelectAiService) selectAiService; + assertThat(agentService.bearerToken).isEqualTo("user-bearer"); + assertThat(agentService.prompt).isEqualTo("버블리즈 유저 마스터 조회해줘."); + assertThat(agentService.showpromptCalled).isTrue(); + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + assertThat(response.path("result").path("content").get(0).path("text").asText()) + .contains("oracle.select_ai.test_data_showprompt") + .contains("SHOWPROMPT") + .contains("assembled Select AI prompt"); } @Test @@ -99,6 +132,7 @@ class McpSseServiceTest { private String bearerToken; private String prompt; + private boolean showpromptCalled; private CapturingSelectAiService() { super(null, null, null, new ObjectMapper()); @@ -117,5 +151,16 @@ class McpSseServiceTest { response.putArray("items").addObject().put("RESULT", 1); return response; } + + @Override + public JsonNode generatePrompt(String bearerToken, String prompt) { + this.bearerToken = bearerToken; + this.prompt = prompt; + this.showpromptCalled = true; + return new ObjectMapper().createObjectNode() + .put("profile", "SGMP_POC_OCI_GPT54MINI") + .put("status", "SHOWPROMPT") + .put("selectAiPrompt", "assembled Select AI prompt"); + } } }