[Developer] add KB Select AI Team ORDS MCP tool
This commit is contained in:
@@ -17,7 +17,7 @@ public record BackofficeProperties(
|
||||
public record Token(int maxDays) {
|
||||
}
|
||||
|
||||
public record Ords(String baseUrl, Duration timeout) {
|
||||
public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) {
|
||||
}
|
||||
|
||||
public record Ai(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -17,4 +18,14 @@ public class OrdsClientConfig {
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Qualifier("ordsAgentRestTemplate")
|
||||
RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) {
|
||||
Duration timeout = properties.ords().agentTimeout();
|
||||
return new RestTemplateBuilder()
|
||||
.setConnectTimeout(timeout)
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,23 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class McpSseService {
|
||||
|
||||
private static final String SELECT_AI_ROUTER_TOOL = "ords.agent.kb_select_ai_router";
|
||||
private static final String SELECT_AI_ROUTER_PATH = "cb-ords/kb-select-ai-agent/run";
|
||||
|
||||
private final McpToolRegistry toolRegistry;
|
||||
private final OrdsProbeService ordsProbeService;
|
||||
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public McpSseService(
|
||||
McpToolRegistry toolRegistry,
|
||||
OrdsProbeService ordsProbeService,
|
||||
SelectAiAgentOrdsService selectAiAgentOrdsService,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.ordsProbeService = ordsProbeService;
|
||||
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -76,6 +82,7 @@ public class McpSseService {
|
||||
item.set("inputSchema", inputSchema(tool));
|
||||
tools.add(item);
|
||||
}
|
||||
tools.add(selectAiRouterTool());
|
||||
result.set("tools", tools);
|
||||
return result;
|
||||
}
|
||||
@@ -116,9 +123,48 @@ public class McpSseService {
|
||||
return schema;
|
||||
}
|
||||
|
||||
private ObjectNode selectAiRouterTool() {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("name", SELECT_AI_ROUTER_TOOL);
|
||||
item.put("description", "Bearer Token으로 ORDS Select AI Team을 호출해 KB 원장 질의용 SQL을 생성합니다. Team의 VPD 컨텍스트가 적용됩니다.");
|
||||
|
||||
ObjectNode schema = objectMapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
ObjectNode properties = objectMapper.createObjectNode();
|
||||
|
||||
ObjectNode bearerToken = objectMapper.createObjectNode();
|
||||
bearerToken.put("type", "string");
|
||||
bearerToken.put("description", "ORDS 호출에 사용할 Bearer Token 원문");
|
||||
properties.set("bearerToken", bearerToken);
|
||||
|
||||
ObjectNode prompt = objectMapper.createObjectNode();
|
||||
prompt.put("type", "string");
|
||||
prompt.put("description", "KB 원장에 대해 생성할 SQL을 자연어로 요청합니다. 이 Team은 읽기 전용 SHOWSQL 생성만 허용합니다.");
|
||||
prompt.put("maxLength", 8000);
|
||||
properties.set("prompt", prompt);
|
||||
|
||||
ObjectNode conversationId = objectMapper.createObjectNode();
|
||||
conversationId.put("type", "string");
|
||||
conversationId.put("description", "선택값. 동일 대화 흐름을 이어갈 때 사용하는 안전한 식별자");
|
||||
conversationId.put("pattern", "^[A-Za-z0-9._:-]{1,128}$");
|
||||
properties.set("conversationId", conversationId);
|
||||
|
||||
schema.set("properties", properties);
|
||||
ArrayNode required = objectMapper.createArrayNode();
|
||||
required.add("bearerToken");
|
||||
required.add("prompt");
|
||||
schema.set("required", required);
|
||||
schema.put("additionalProperties", false);
|
||||
item.set("inputSchema", schema);
|
||||
return item;
|
||||
}
|
||||
|
||||
private ObjectNode toolsCallResult(JsonNode params) {
|
||||
String toolName = params.path("name").asText("");
|
||||
JsonNode arguments = params.path("arguments");
|
||||
if (SELECT_AI_ROUTER_TOOL.equals(toolName)) {
|
||||
return selectAiRouterCallResult(arguments);
|
||||
}
|
||||
McpToolView tool = findTool(toolName);
|
||||
String bearerToken = arguments.path("bearerToken").asText("");
|
||||
int limit = normalizeLimit(arguments.path("limit").asInt(50));
|
||||
@@ -163,6 +209,30 @@ public class McpSseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode selectAiRouterCallResult(JsonNode arguments) {
|
||||
JsonNode response = selectAiAgentOrdsService.run(
|
||||
arguments.path("bearerToken").asText(""),
|
||||
arguments.path("prompt").asText(""),
|
||||
arguments.path("conversationId").asText("")
|
||||
);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", SELECT_AI_ROUTER_TOOL);
|
||||
payload.put("team", "KB_SELECT_AI_ROUTER_TEAM");
|
||||
payload.put("ordsPath", SELECT_AI_ROUTER_PATH);
|
||||
payload.set("response", response);
|
||||
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode content = objectMapper.createArrayNode();
|
||||
ObjectNode text = objectMapper.createObjectNode();
|
||||
text.put("type", "text");
|
||||
text.put("text", pretty(payload));
|
||||
content.add(text);
|
||||
result.set("content", content);
|
||||
result.put("isError", false);
|
||||
return result;
|
||||
}
|
||||
|
||||
private McpToolView findTool(String toolName) {
|
||||
List<McpToolView> tools = toolRegistry.listTools();
|
||||
return tools.stream()
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
@@ -84,7 +85,7 @@ public class OrdsProbeService {
|
||||
ProtectedObjectService protectedObjectService,
|
||||
AuditService auditService,
|
||||
ProbeErrorClassifier errorClassifier,
|
||||
RestTemplate ordsRestTemplate,
|
||||
@Qualifier("ordsRestTemplate") RestTemplate ordsRestTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
SettingService settingService,
|
||||
JdbcTemplate jdbcTemplate,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.net.URI;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/** Calls the ORDS boundary; the database endpoint owns bearer-to-VPD-context mapping. */
|
||||
@Service
|
||||
public class SelectAiAgentOrdsService {
|
||||
|
||||
static final String ORDS_PATH = "/cb-ords/kb-select-ai-agent/run";
|
||||
private static final int MAX_PROMPT_LENGTH = 8_000;
|
||||
|
||||
private final SettingService settingService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SelectAiAgentOrdsService(
|
||||
SettingService settingService,
|
||||
@Qualifier("ordsAgentRestTemplate") RestTemplate restTemplate,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.settingService = settingService;
|
||||
this.restTemplate = restTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public JsonNode run(String bearerToken, String prompt, String conversationId) {
|
||||
String normalizedToken = required(bearerToken, "bearerToken");
|
||||
String normalizedPrompt = required(prompt, "prompt");
|
||||
if (normalizedPrompt.length() > MAX_PROMPT_LENGTH) {
|
||||
throw new AppException("prompt는 " + MAX_PROMPT_LENGTH + "자 이하여야 합니다.");
|
||||
}
|
||||
String normalizedConversationId = normalizeConversationId(conversationId);
|
||||
String baseUrl = settingService.ordsBaseUrl();
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
throw new AppException("ORDS base URL이 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
ObjectNode requestBody = objectMapper.createObjectNode();
|
||||
requestBody.put("prompt", normalizedPrompt);
|
||||
if (normalizedConversationId != null) {
|
||||
requestBody.put("conversationId", normalizedConversationId);
|
||||
}
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(normalizedToken);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-VPD-Probe-Id", UUID.randomUUID().toString());
|
||||
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
endpoint(baseUrl),
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(requestBody.toString(), headers),
|
||||
String.class
|
||||
);
|
||||
JsonNode body = parse(response.getBody());
|
||||
if (body.hasNonNull("error")) {
|
||||
throw new AppException("Select AI Agent ORDS 오류: " + body.path("error").asText());
|
||||
}
|
||||
return body;
|
||||
} catch (HttpStatusCodeException e) {
|
||||
throw new AppException("Select AI Agent ORDS HTTP " + e.getStatusCode().value()
|
||||
+ ": " + responseError(e.getResponseBodyAsString()));
|
||||
} catch (ResourceAccessException e) {
|
||||
throw new AppException("Select AI Agent ORDS 연결 또는 응답 시간 초과: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private URI endpoint(String baseUrl) {
|
||||
return UriComponentsBuilder.fromUriString(baseUrl)
|
||||
.path(ORDS_PATH)
|
||||
.build()
|
||||
.toUri();
|
||||
}
|
||||
|
||||
private JsonNode parse(String value) {
|
||||
try {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException("Select AI Agent ORDS 응답 본문이 비어 있습니다.");
|
||||
}
|
||||
return objectMapper.readTree(value);
|
||||
} catch (AppException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new AppException("Select AI Agent ORDS 응답 JSON 파싱 실패: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String responseError(String body) {
|
||||
try {
|
||||
JsonNode parsed = objectMapper.readTree(body);
|
||||
return parsed.path("error").asText(body == null ? "" : body);
|
||||
} catch (Exception ignored) {
|
||||
return body == null ? "" : body;
|
||||
}
|
||||
}
|
||||
|
||||
private String required(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException(name + "은(는) 필수입니다.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String normalizeConversationId(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (!normalized.matches("[A-Za-z0-9._:-]{1,128}")) {
|
||||
throw new AppException("conversationId는 영문/숫자/._:-만 사용하고 128자 이하여야 합니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ backoffice:
|
||||
ords:
|
||||
base-url: ${BACKOFFICE_ORDS_BASE_URL:https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}
|
||||
timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s
|
||||
agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s
|
||||
metadata-db:
|
||||
url: ${BACKOFFICE_ORDS_DB_URL:}
|
||||
username: ${BACKOFFICE_ORDS_DB_USERNAME:}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class McpSseServiceTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final SelectAiAgentOrdsService selectAiAgentOrdsService = new CapturingSelectAiAgentOrdsService();
|
||||
private final McpSseService service = new McpSseService(
|
||||
new EmptyToolRegistry(),
|
||||
null,
|
||||
selectAiAgentOrdsService,
|
||||
objectMapper
|
||||
);
|
||||
|
||||
@Test
|
||||
void listsSelectAiRouterToolWithPromptAndBearerInputs() {
|
||||
ObjectNode response = service.handle("default", request(1, "tools/list"));
|
||||
|
||||
var tools = response.path("result").path("tools");
|
||||
var tool = tools.findValuesAsText("name").indexOf("ords.agent.kb_select_ai_router");
|
||||
assertThat(tool).isGreaterThanOrEqualTo(0);
|
||||
var router = tools.get(tool);
|
||||
assertThat(router.path("inputSchema").path("required"))
|
||||
.extracting(node -> node.asText())
|
||||
.contains("bearerToken", "prompt");
|
||||
assertThat(router.path("inputSchema").path("properties").has("conversationId")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsSelectAiRouterThroughOrdsService() {
|
||||
ObjectNode request = request(2, "tools/call");
|
||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||
params.put("name", "ords.agent.kb_select_ai_router");
|
||||
ObjectNode arguments = params.putObject("arguments");
|
||||
arguments.put("bearerToken", "user-bearer");
|
||||
arguments.put("prompt", "고객 수를 세는 SQL을 만들어줘");
|
||||
arguments.put("conversationId", "smoke-1");
|
||||
|
||||
ObjectNode response = service.handle("default", request);
|
||||
|
||||
CapturingSelectAiAgentOrdsService agentService =
|
||||
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService;
|
||||
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
||||
assertThat(agentService.prompt).isEqualTo("고객 수를 세는 SQL을 만들어줘");
|
||||
assertThat(agentService.conversationId).isEqualTo("smoke-1");
|
||||
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())
|
||||
.contains("KB_SELECT_AI_ROUTER_TEAM")
|
||||
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS");
|
||||
}
|
||||
|
||||
private ObjectNode request(int id, String method) {
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("jsonrpc", "2.0");
|
||||
request.put("id", id);
|
||||
request.put("method", method);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static final class EmptyToolRegistry extends McpToolRegistry {
|
||||
|
||||
private EmptyToolRegistry() {
|
||||
super(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView> listTools() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService {
|
||||
|
||||
private String bearerToken;
|
||||
private String prompt;
|
||||
private String conversationId;
|
||||
|
||||
private CapturingSelectAiAgentOrdsService() {
|
||||
super(null, null, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode run(String bearerToken, String prompt, String conversationId) {
|
||||
this.bearerToken = bearerToken;
|
||||
this.prompt = prompt;
|
||||
this.conversationId = conversationId;
|
||||
return new ObjectMapper().createObjectNode()
|
||||
.put("team", "KB_SELECT_AI_ROUTER_TEAM")
|
||||
.put("answer", "SELECT COUNT(*) FROM KB_CUSTOMERS");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user