refs #726: add Select AI SHOWPROMPT diagnostic tool

This commit is contained in:
devmrko
2026-07-23 20:27:14 +09:00
parent 53342e7bc3
commit 23bc4424e3
6 changed files with 213 additions and 31 deletions

View File

@@ -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();
}

View File

@@ -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<McpToolView> 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);

View File

@@ -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());
}
}

View File

@@ -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:

View File

@@ -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");
}
}
}