refs #731: add QA vector MCP tools

This commit is contained in:
devmrko
2026-07-24 14:41:00 +09:00
parent 88a292d711
commit e7213eabb5
8 changed files with 522 additions and 25 deletions

View File

@@ -11,7 +11,13 @@ public record McpProperties(
String promptDescription,
String showpromptToolName,
String showpromptToolLabel,
String showpromptToolDescription
String showpromptToolDescription,
String qaVectorSearchToolName,
String qaVectorSearchToolLabel,
String qaVectorSearchToolDescription,
String qaVectorStoreToolName,
String qaVectorStoreToolLabel,
String qaVectorStoreToolDescription
) {
private static final String DEFAULT_TOOL_NAME = "oracle.select_ai.data_text2sql";
@@ -28,6 +34,18 @@ public record McpProperties(
private static final String DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION =
"Select AI가 SQL 생성에 사용한 prompt를 조회하는 읽기 전용 진단 도구입니다. "
+ "생성 SQL이나 데이터 조회 SQL은 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME =
"oracle.select_ai.qa_vector_search";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL = "Select AI 예제 SQL 조회";
private static final String DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION =
"현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. "
+ "반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_NAME =
"oracle.select_ai.qa_vector_store";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_LABEL = "Select AI 예제 SQL 저장";
private static final String DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION =
"검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. "
+ "질문과 읽기 전용 답 SQL이 필요합니다.";
public String resolvedToolName() {
return requiredOrDefault(toolName, DEFAULT_TOOL_NAME);
@@ -57,6 +75,30 @@ public record McpProperties(
return requiredOrDefault(showpromptToolDescription, DEFAULT_SHOWPROMPT_TOOL_DESCRIPTION);
}
public String resolvedQaVectorSearchToolName() {
return requiredOrDefault(qaVectorSearchToolName, DEFAULT_QA_VECTOR_SEARCH_TOOL_NAME);
}
public String resolvedQaVectorSearchToolLabel() {
return requiredOrDefault(qaVectorSearchToolLabel, DEFAULT_QA_VECTOR_SEARCH_TOOL_LABEL);
}
public String resolvedQaVectorSearchToolDescription() {
return requiredOrDefault(qaVectorSearchToolDescription, DEFAULT_QA_VECTOR_SEARCH_TOOL_DESCRIPTION);
}
public String resolvedQaVectorStoreToolName() {
return requiredOrDefault(qaVectorStoreToolName, DEFAULT_QA_VECTOR_STORE_TOOL_NAME);
}
public String resolvedQaVectorStoreToolLabel() {
return requiredOrDefault(qaVectorStoreToolLabel, DEFAULT_QA_VECTOR_STORE_TOOL_LABEL);
}
public String resolvedQaVectorStoreToolDescription() {
return requiredOrDefault(qaVectorStoreToolDescription, DEFAULT_QA_VECTOR_STORE_TOOL_DESCRIPTION);
}
private String requiredOrDefault(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}

View File

@@ -20,17 +20,20 @@ public class McpSseService {
private final ObjectMapper objectMapper;
private final BackofficeProperties properties;
private final McpProperties mcpProperties;
private final QaVectorService qaVectorService;
public McpSseService(
SelectAiService selectAiService,
ObjectMapper objectMapper,
BackofficeProperties properties,
McpProperties mcpProperties
McpProperties mcpProperties,
QaVectorService qaVectorService
) {
this.selectAiService = selectAiService;
this.objectMapper = objectMapper;
this.properties = properties;
this.mcpProperties = mcpProperties;
this.qaVectorService = qaVectorService;
}
public ObjectNode handle(String contextPath, JsonNode request) {
@@ -69,7 +72,8 @@ public class McpSseService {
/** Tools registered by this MCP server. */
public List<McpToolView> registeredTools() {
return List.of(selectAiQueryView(), selectAiShowpromptView());
return List.of(
selectAiQueryView(), selectAiShowpromptView(), qaVectorSearchView(), qaVectorStoreView());
}
private ObjectNode initializeResult(String contextPath) {
@@ -90,6 +94,8 @@ public class McpSseService {
ArrayNode tools = objectMapper.createArrayNode();
tools.add(toolDefinition(selectAiQueryView()));
tools.add(toolDefinition(selectAiShowpromptView()));
tools.add(toolDefinition(qaVectorSearchView()));
tools.add(toolDefinition(qaVectorStoreView()));
result.set("tools", tools);
return result;
}
@@ -103,26 +109,47 @@ public class McpSseService {
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
ObjectNode prompt = objectMapper.createObjectNode();
prompt.put("type", "string");
prompt.put("description", promptDescription());
prompt.put("maxLength", 4000);
properties.set("prompt", prompt);
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("prompt");
if (qaVectorSearchToolName().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);
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);
if (!queryTool && !showpromptTool) {
boolean qaVectorSearchTool = qaVectorSearchToolName().equals(calledToolName);
boolean qaVectorStoreTool = qaVectorStoreToolName().equals(calledToolName);
if (!queryTool && !showpromptTool && !qaVectorSearchTool && !qaVectorStoreTool) {
throw new AppException("등록되지 않은 MCP tool입니다: " + calledToolName);
}
@@ -133,10 +160,22 @@ public class McpSseService {
}
JsonNode response;
try {
String prompt = arguments.path("prompt").asText("");
response = queryTool
? selectAiService.generateAndExecute(token, prompt)
: selectAiService.generatePrompt(token, prompt);
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 {
String prompt = arguments.path("prompt").asText("");
response = queryTool
? selectAiService.generateAndExecute(token, prompt)
: selectAiService.generatePrompt(token, prompt);
}
} catch (VpdTokenAccessDeniedException ignored) {
return tokenAccessDeniedResult();
}
@@ -158,6 +197,37 @@ public class McpSseService {
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 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;
}
private ObjectNode tokenAccessDeniedResult() {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("status", "VPD_TOKEN_DENIED");
@@ -196,6 +266,18 @@ public class McpSseService {
);
}
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 String selectAiProfile() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || selectAi.profile() == null || selectAi.profile().isBlank()) {
@@ -240,6 +322,38 @@ public class McpSseService {
: 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 pretty(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);

View File

@@ -0,0 +1,174 @@
package com.cloudhandson.vpdbackoffice.service;
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Types;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/** Calls the SGMP_POC QA-vector database API through the schema-owned Select AI connection. */
@Service
public class QaVectorService {
private static final int MAX_QUESTION_LENGTH = 4_000;
private static final int MAX_ANSWER_SQL_LENGTH = 20_000;
private static final int MAX_ANSWER_LENGTH = 20_000;
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|dbms_[a-z0-9_]*|"
+ "utl_[a-z0-9_]*|sys\\s*\\.)\\b"
);
private final BackofficeProperties properties;
private final BearerTokenService bearerTokenService;
private final Clock clock;
public QaVectorService(
BackofficeProperties properties,
BearerTokenService bearerTokenService,
Clock clock
) {
this.properties = properties;
this.bearerTokenService = bearerTokenService;
this.clock = clock;
}
public VectorSearchResult search(String bearerToken, String question, int topK) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
if (topK < 1 || topK > 20) {
throw new AppException("topK는 1에서 20 사이여야 합니다.");
}
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
List<VectorExample> examples = new ArrayList<>();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
CallableStatement statement = connection.prepareCall("{ ? = call sg_qa_vector_search(?, ?) }")) {
statement.registerOutParameter(1, Types.REF_CURSOR);
statement.setString(2, normalizedQuestion);
statement.setInt(3, topK);
statement.execute();
try (ResultSet resultSet = (ResultSet) statement.getObject(1)) {
while (resultSet.next()) {
examples.add(new VectorExample(
resultSet.getLong("EXAMPLE_ID"),
resultSet.getString("QUESTION"),
resultSet.getString("ANSWER_SQL"),
resultSet.getString("ANSWER_TEXT"),
resultSet.getString("EMBEDDING_MODEL"),
resultSet.getDouble("COSINE_DISTANCE")
));
}
}
} catch (Exception exception) {
throw new AppException("QA 벡터 예제 SQL 조회 실패: " + exception.getMessage());
}
return new VectorSearchResult(normalizedQuestion, topK, examples);
}
public VectorStoreResult store(String bearerToken, String question, String answerSql, String answer) {
requireActiveToken(bearerToken);
String normalizedQuestion = requiredText(question, "question", MAX_QUESTION_LENGTH);
String normalizedAnswerSql = requiredReadOnlySql(answerSql);
String normalizedAnswer = optionalText(answer, "answer", MAX_ANSWER_LENGTH);
BackofficeProperties.SelectAi selectAi = requiredSelectAi();
try (Connection connection = DriverManager.getConnection(
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword());
PreparedStatement statement = connection.prepareStatement(
"SELECT sg_qa_vector_store(?, ?, ?) AS example_id FROM dual")) {
statement.setString(1, normalizedQuestion);
statement.setString(2, normalizedAnswerSql);
statement.setString(3, normalizedAnswer);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
throw new AppException("QA 벡터 예제 SQL 저장 결과가 없습니다.");
}
return new VectorStoreResult(
resultSet.getLong("EXAMPLE_ID"), normalizedQuestion, "cohere.embed-v4.0");
}
} catch (AppException exception) {
throw exception;
} catch (Exception exception) {
throw new AppException("QA 벡터 예제 SQL 저장 실패: " + exception.getMessage());
}
}
private BackofficeProperties.SelectAi requiredSelectAi() {
BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi();
if (selectAi == null || !selectAi.configured()) {
throw new AppException("QA 벡터 연결 설정이 필요합니다. "
+ "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();
}
BearerTokenRecord token = bearerTokenService.findByPlainToken(bearerToken.trim());
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
if (token == null || !token.active(now)) {
throw new VpdTokenAccessDeniedException();
}
}
private String requiredReadOnlySql(String answerSql) {
String normalized = requiredText(answerSql, "answerSql", MAX_ANSWER_SQL_LENGTH)
.replaceFirst(";\\s*$", "").trim();
if (!normalized.matches("(?is)^(select|with)\\b.*") || normalized.contains(";")
|| normalized.contains("--") || normalized.contains("/*") || normalized.contains("*/")
|| UNSAFE_SQL.matcher(normalized).find()) {
throw new AppException("answerSql은 단일 읽기 전용 SELECT/WITH SQL이어야 합니다.");
}
return normalized;
}
private String requiredText(String value, String fieldName, int maximumLength) {
String normalized = optionalText(value, fieldName, maximumLength);
if (normalized == null) {
throw new AppException(fieldName + "는 필수입니다.");
}
return normalized;
}
private String optionalText(String value, String fieldName, int maximumLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
return null;
}
if (normalized.length() > maximumLength) {
throw new AppException(fieldName + "" + maximumLength + "자 이하여야 합니다.");
}
return normalized;
}
public record VectorExample(
long exampleId,
String question,
String answerSql,
String answer,
String embeddingModel,
double cosineDistance
) {
}
public record VectorSearchResult(String question, int topK, List<VectorExample> examples) {
}
public record VectorStoreResult(long exampleId, String question, String embeddingModel) {
}
}

View File

@@ -90,6 +90,12 @@ backoffice:
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은 실행하지 않습니다.}
qa-vector-search-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_NAME:oracle.select_ai.qa_vector_search}
qa-vector-search-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_LABEL:Select AI 예제 SQL 조회}
qa-vector-search-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_SEARCH_TOOL_DESCRIPTION:현재 질문에 사용할 유사 예제 SQL을 Select AI 실행 전에 조회합니다. 반환값은 few-shot 컨텍스트 검토용이며 SQL을 실행하지 않습니다.}
qa-vector-store-tool-name: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_NAME:oracle.select_ai.qa_vector_store}
qa-vector-store-tool-label: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_LABEL:Select AI 예제 SQL 저장}
qa-vector-store-tool-description: ${BACKOFFICE_MCP_QA_VECTOR_STORE_TOOL_DESCRIPTION:검토된 Select AI 결과를 후속 Text2SQL 품질 향상용 예제 SQL로 저장합니다. 질문과 읽기 전용 답 SQL이 필요합니다.}
masking:
policies: ${BACKOFFICE_MASKING_POLICIES:}
security-sql-scripts:

View File

@@ -8,7 +8,7 @@
<h1>MCP 연동</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일로 업무 데이터 Text2SQL을 생성하는 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하며, 생성 SQL은 자동 실행하지 않습니다.</p>
<p>사용자 Bearer Token을 검증한 뒤 구성된 Select AI 프로파일 Text2SQL·SHOWPROMPT와 QA 벡터 예제 SQL 조회·저장 MCP tool을 제공합니다. QA 조회는 SQL 생성 전에 few-shot 후보를 확인하는 용도이며, 저장은 검토된 예제를 축적하는 용도입니다.</p>
</details>
</section>
@@ -91,7 +91,7 @@
<td>
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
<small class="text-muted">
HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · <code>prompt</code> → 업무 데이터 Text2SQL 생성 · 결과 SQL 검토 후 별도 실행
HTTP <code>Authorization</code> → 활성 사용자 토큰 검증 · 도구별 <code>prompt</code> 또는 <code>question</code> 입력 · QA 조회 결과는 few-shot 예제 SQL 검토
</small>
</td>
</tr>
@@ -107,10 +107,19 @@
<details class="explanation-details">
<summary>tools/call parameter 예시 보기</summary>
<h2>tools/call Arguments</h2>
<pre class="code-block">{
"prompt": "최신 기준 활성 사용자 수를 조회하는 SQL을 만들어줘."
<pre class="code-block">// qa_vector_search
{
"question": "최신 기준 활성 사용자 수를 조회해줘.",
"topK": 3
}
// qa_vector_store
{
"question": "최신 기준 활성 사용자 수를 조회해줘.",
"answerSql": "SELECT COUNT(*) AS AU_COUNT FROM ...",
"answer": "AU_COUNT=123"
}</pre>
<p class="form-hint">등록 tool은 <code th:text="${mcp?.resolvedToolName() ?: 'oracle.select_ai.data_text2sql'}">oracle.select_ai.data_text2sql</code> 하나입니다. 활성 사용자 Bearer Token을 확인한 뒤 Select AI가 comment, annotation, constraint를 참고해 읽기 전용 업무 데이터 SQL을 생성합니다. 실행은 자동으로 수행하지 않습니다.</p>
<p class="form-hint">Text2SQL·SHOWPROMPT는 <code>prompt</code>를 사용하고, <code th:text="${mcp?.resolvedQaVectorSearchToolName() ?: 'oracle.select_ai.qa_vector_search'}">oracle.select_ai.qa_vector_search</code>는 few-shot 후보를 조회하며 <code th:text="${mcp?.resolvedQaVectorStoreToolName() ?: 'oracle.select_ai.qa_vector_store'}">oracle.select_ai.qa_vector_store</code>는 검토된 읽기 전용 예제 SQL을 저장합니다.</p>
</details>
</section>