diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index a563acd..4cc7b72 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -8,7 +8,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.List; import org.springframework.stereotype.Service; -/** MCP boundary exposing the Smilegate game-data Select AI SHOWSQL tool. */ +/** MCP boundary exposing the Smilegate game-data Select AI generation and read-only execution tool. */ @Service public class McpSseService { @@ -17,7 +17,7 @@ public class McpSseService { private static final String SELECT_AI_VPD_QUERY_PROFILE = "SGMP_POC_HAIKU45"; private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView( SELECT_AI_VPD_QUERY_TOOL, - "SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성합니다. 생성 SQL은 자동 실행하지 않으며 테이블·컬럼 comment, annotation, constraint를 참고합니다.", + "SGMP_POC_HAIKU45 프로파일로 게임 로그·서비스 데이터용 읽기 전용 SELECT/WITH SQL을 생성하고, 검증 후 읽기 전용 트랜잭션에서 실행합니다. 생성 SQL과 최대 100건의 조회 결과를 함께 반환하며 DDL/DML/잠금/패키지 호출은 실행하지 않습니다.", -1L, "Smilegate 게임 데이터 Text2SQL", SELECT_AI_VPD_QUERY_PATH @@ -131,7 +131,7 @@ public class McpSseService { } JsonNode response; try { - response = smilegateSelectAiService.generateShowSql(token, arguments.path("prompt").asText("")); + response = smilegateSelectAiService.generateAndExecute(token, arguments.path("prompt").asText("")); } catch (VpdTokenAccessDeniedException ignored) { return tokenAccessDeniedResult(); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java index dc367c5..09946aa 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SmilegateSelectAiService.java @@ -4,25 +4,36 @@ import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; 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.math.BigDecimal; +import java.math.BigInteger; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.regex.Pattern; import org.springframework.stereotype.Service; /** - * Generates reviewed read-only SQL through the schema-owned Smilegate Select AI profile. - * The generated SQL is never executed by this service. + * Generates and executes bounded read-only SQL through the schema-owned Smilegate Select AI profile. */ @Service public class SmilegateSelectAiService { private static final int MAX_PROMPT_LENGTH = 4_000; - private static final String ACTION_SHOWSQL = "showsql"; + private static final int MAX_RESULT_ROWS = 100; + private static final int QUERY_TIMEOUT_SECONDS = 30; + private static final Pattern UNSAFE_SQL = Pattern.compile( + "(?is)\\b(?:insert|update|delete|merge|alter|drop|create|truncate|grant|revoke|" + + "commit|rollback|savepoint|lock|call|exec(?:ute)?|begin|declare|for\\s+update|" + + "dbms_[a-z0-9_]*|utl_[a-z0-9_]*|sys\\s*\\.)\\b" + ); private final BackofficeProperties properties; private final BearerTokenService bearerTokenService; @@ -41,7 +52,7 @@ public class SmilegateSelectAiService { this.objectMapper = objectMapper; } - public JsonNode generateShowSql(String bearerToken, String prompt) { + public JsonNode generateAndExecute(String bearerToken, String prompt) { requireActiveToken(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); @@ -53,12 +64,18 @@ public class SmilegateSelectAiService { String generatedSql = generate(selectAi, normalizedPrompt); String normalizedSql = validateReadOnlySql(generatedSql); + QueryExecution execution = executeReadOnly(selectAi, normalizedSql); ObjectNode response = objectMapper.createObjectNode(); - response.put("status", "SHOWSQL"); + response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); response.put("generatedSql", normalizedSql); - response.put("execution", "NOT_EXECUTED"); - response.put("nextStep", "생성 SQL을 검토한 뒤 Database Actions 또는 승인된 실행 경로에서 실행하세요."); + response.put("execution", "READ_ONLY_EXECUTED"); + response.put("rowCount", execution.items().size()); + response.put("truncated", execution.truncated()); + response.set("items", execution.items()); + response.put("nextStep", execution.truncated() + ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다. 생성 SQL로 전체 결과를 확인할 수 있습니다." + : "생성 SQL을 읽기 전용으로 실행한 결과입니다."); return response; } @@ -104,6 +121,68 @@ public class SmilegateSelectAiService { } } + private QueryExecution executeReadOnly(BackofficeProperties.SelectAi selectAi, String generatedSql) { + ArrayNode items = objectMapper.createArrayNode(); + boolean truncated = false; + try (Connection connection = DriverManager.getConnection( + selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword()); + Statement transaction = connection.createStatement()) { + connection.setReadOnly(true); + transaction.execute("SET TRANSACTION READ ONLY"); + try (PreparedStatement statement = connection.prepareStatement(generatedSql)) { + statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); + statement.setFetchSize(MAX_RESULT_ROWS + 1); + statement.setMaxRows(MAX_RESULT_ROWS + 1); + try (ResultSet resultSet = statement.executeQuery()) { + ResultSetMetaData metadata = resultSet.getMetaData(); + while (resultSet.next()) { + if (items.size() >= MAX_RESULT_ROWS) { + truncated = true; + break; + } + ObjectNode row = items.addObject(); + for (int columnIndex = 1; columnIndex <= metadata.getColumnCount(); columnIndex++) { + String column = metadata.getColumnLabel(columnIndex); + if (column == null || column.isBlank()) { + column = metadata.getColumnName(columnIndex); + } + putResultValue(row, column, resultSet.getObject(columnIndex)); + } + } + } + } finally { + connection.rollback(); + } + } catch (Exception exception) { + throw new AppException("Smilegate Select AI 생성 SQL 실행 실패: " + exception.getMessage()); + } + return new QueryExecution(items, truncated); + } + + private void putResultValue(ObjectNode row, String column, Object value) { + if (value == null) { + row.putNull(column); + } else if (value instanceof BigDecimal number) { + row.put(column, number); + } else if (value instanceof BigInteger number) { + row.put(column, number); + } else if (value instanceof Integer number) { + row.put(column, number); + } else if (value instanceof Long number) { + row.put(column, number); + } else if (value instanceof Short number) { + row.put(column, number); + } else if (value instanceof Float number) { + row.put(column, number); + } else if (value instanceof Double number) { + row.put(column, number); + } else if (value instanceof Boolean bool) { + row.put(column, bool); + } else { + row.put(column, String.valueOf(value)); + } + } + private String validateReadOnlySql(String generatedSql) { String normalized = generatedSql == null ? "" : generatedSql.trim(); if (normalized.startsWith("```")) { @@ -120,6 +199,12 @@ public class SmilegateSelectAiService { if (normalized.contains(";")) { throw new AppException("Select AI 결과에 여러 SQL 문장이 포함되어 있어 반환하지 않습니다."); } + if (normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/") + || UNSAFE_SQL.matcher(normalized).find()) { + throw new AppException("Select AI 결과에 실행이 허용되지 않는 SQL 구문이 포함되어 있습니다."); + } return normalized; } + + private record QueryExecution(ArrayNode items, boolean truncated) {} } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index d312f0f..7980879 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -47,9 +47,13 @@ class McpSseServiceTest { assertThat(agentService.prompt).isEqualTo("카제나 AU를 조회해 줘"); assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("result").path("isError").asBoolean()).isFalse(); - assertThat(response.path("result").path("content").get(0).path("text").asText()) + String payload = response.path("result").path("content").get(0).path("text").asText(); + assertThat(payload) .contains("SGMP_POC_HAIKU45") - .contains("SELECT 1 FROM DUAL"); + .contains("SELECT 1 FROM DUAL") + .contains("SHOWSQL_AND_EXECUTED") + .contains("READ_ONLY_EXECUTED") + .contains("RESULT"); } @Test @@ -86,12 +90,17 @@ class McpSseServiceTest { } @Override - public JsonNode generateShowSql(String bearerToken, String prompt) { + public JsonNode generateAndExecute(String bearerToken, String prompt) { this.bearerToken = bearerToken; this.prompt = prompt; - return new ObjectMapper().createObjectNode() + ObjectNode response = new ObjectMapper().createObjectNode() .put("profile", "SGMP_POC_HAIKU45") - .put("generatedSql", "SELECT 1 FROM DUAL"); + .put("generatedSql", "SELECT 1 FROM DUAL") + .put("status", "SHOWSQL_AND_EXECUTED") + .put("execution", "READ_ONLY_EXECUTED") + .put("rowCount", 1); + response.putArray("items").addObject().put("RESULT", 1); + return response; } } }