[Developer] add KB Select AI Team ORDS MCP tool

This commit is contained in:
devmrko
2026-07-08 06:43:29 +09:00
parent 1cb8052045
commit 61f7e4c775
11 changed files with 635 additions and 2 deletions

View File

@@ -0,0 +1,104 @@
-- ============================================================
-- 56_kb_select_ai_router_team.sql
--
-- KB Select AI profile을 호출하는 최소 Agent / Tool / Task / Team 체인.
-- Tool -> Task -> Router Agent -> Team
-- Execute as POC_2 using SQLcl.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET DEFINE OFF
SET FEEDBACK ON
PROMPT === Resetting only this Select AI router chain ===
BEGIN
DBMS_CLOUD_AI_AGENT.DROP_TEAM('KB_SELECT_AI_ROUTER_TEAM', force => TRUE);
DBMS_CLOUD_AI_AGENT.DROP_TASK('KB_SELECT_AI_ROUTER_TASK', force => TRUE);
DBMS_CLOUD_AI_AGENT.DROP_AGENT('KB_SELECT_AI_ROUTER_AGENT', force => TRUE);
DBMS_CLOUD_AI_AGENT.DROP_TOOL('KB_SELECT_AI_ROUTER_TOOL', force => TRUE);
END;
/
PROMPT === Creating Select AI SQL tool ===
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_TOOL(
tool_name => 'KB_SELECT_AI_ROUTER_TOOL',
attributes => q'~{
"tool_type": "SQL",
"tool_params": {
"profile_name": "KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2"
},
"instruction": "Use the configured Select AI profile for structured KB insurance questions. For this demo, generate SHOWSQL only: create a read-only SELECT or WITH statement, do not execute it, and never generate DDL, DML, transaction control, package calls, or data changes."
}~',
status => 'ENABLED',
description => 'Calls KB_AIDP_SELECTAI_GPT55_OCI_PROFILE_V2 to generate read-only KB SQL.'
);
END;
/
PROMPT === Creating router agent ===
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_AGENT(
agent_name => 'KB_SELECT_AI_ROUTER_AGENT',
attributes => q'~{
"profile_name": "KB_AIDP_AGENT_GPT55_OCI_PROFILE_V2",
"role": "You are the KB Select AI routing agent. Classify every user request. If it asks for KB structured insurance data, metrics, counts, contracts, claims, customers, products, coverage, holdings, or stakeholders, call the assigned KB Select AI SQL tool. Return a concise Korean answer and the generated SQL. Do not invent data, do not expose internal reasoning, and do not use any unassigned tool.",
"enable_human_tool": false
}~',
status => 'ENABLED',
description => 'Routes KB structured-data questions to the Select AI SQL tool.'
);
END;
/
PROMPT === Registering tool task ===
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_TASK(
task_name => 'KB_SELECT_AI_ROUTER_TASK',
attributes => q'~{
"instruction": "Route this user request to the KB Select AI SQL tool when it requires structured insurance data: {query}. Use SHOWSQL only. Explain briefly in Korean what the generated SQL will retrieve. If the request is outside the KB structured-data scope, state that this team only handles KB structured-data SQL generation.",
"tools": ["KB_SELECT_AI_ROUTER_TOOL"],
"enable_human_tool": false
}~',
status => 'ENABLED',
description => 'Single Select AI SQL generation task for KB structured-data requests.'
);
END;
/
PROMPT === Creating agent task team ===
BEGIN
DBMS_CLOUD_AI_AGENT.CREATE_TEAM(
team_name => 'KB_SELECT_AI_ROUTER_TEAM',
attributes => q'~{
"agents": [
{
"name": "KB_SELECT_AI_ROUTER_AGENT",
"task": "KB_SELECT_AI_ROUTER_TASK"
}
],
"process": "sequential"
}~',
status => 'ENABLED',
description => 'KB Select AI router Team: SQL tool, task, agent, and team.'
);
END;
/
PROMPT === Registered chain ===
SELECT tool_name, status
FROM USER_AI_AGENT_TOOLS
WHERE tool_name = 'KB_SELECT_AI_ROUTER_TOOL';
SELECT agent_name, status
FROM USER_AI_AGENTS
WHERE agent_name = 'KB_SELECT_AI_ROUTER_AGENT';
SELECT task_name, status
FROM USER_AI_AGENT_TASKS
WHERE task_name = 'KB_SELECT_AI_ROUTER_TASK';
SELECT agent_team_name, status
FROM USER_AI_AGENT_TEAMS
WHERE agent_team_name = 'KB_SELECT_AI_ROUTER_TEAM';
EXIT

View File

@@ -0,0 +1,17 @@
-- Execute as POC_2 using SQLcl after 56.
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET PAGESIZE 100
SET LINESIZE 240
SET LONG 30000
SET LONGCHUNKSIZE 30000
SET FEEDBACK ON
SELECT DBMS_CLOUD_AI_AGENT.RUN_TEAM(
team_name => 'KB_SELECT_AI_ROUTER_TEAM',
user_prompt => 'KB 고객 원장의 전체 고객 수를 계산하는 SQL을 만들어 줘. SQL만 생성하고 실행하지 마.',
params => '{"conversation_id":"kb-select-ai-router-' ||
TO_CHAR(SYSTIMESTAMP, 'YYYYMMDDHH24MISSFF3') || '"}'
) AS agent_response
FROM dual;
EXIT

View File

@@ -0,0 +1,57 @@
-- ============================================================
-- 58_kb_select_ai_router_api.sql
--
-- POC_2 소유 Select AI Team을 ORDS runtime(CB_ORDS)에서 안전하게
-- 호출하기 위한 definer-rights API.
--
-- Execute as POC_2 using SQLcl after 56_kb_select_ai_router_team.sql.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Creating POC_2 Select AI Team API ===
CREATE OR REPLACE PACKAGE kb_select_ai_router_api AUTHID DEFINER AS
FUNCTION run_team(
p_prompt IN CLOB,
p_conversation_id IN VARCHAR2 DEFAULT NULL
) RETURN CLOB;
END kb_select_ai_router_api;
/
CREATE OR REPLACE PACKAGE BODY kb_select_ai_router_api AS
FUNCTION run_team(
p_prompt IN CLOB,
p_conversation_id IN VARCHAR2 DEFAULT NULL
) RETURN CLOB AS
v_params CLOB;
v_conversation_id VARCHAR2(128) := TRIM(p_conversation_id);
BEGIN
IF p_prompt IS NULL OR DBMS_LOB.GETLENGTH(TRIM(p_prompt)) = 0 THEN
RAISE_APPLICATION_ERROR(-20801, 'prompt is required');
END IF;
IF v_conversation_id IS NOT NULL THEN
IF NOT REGEXP_LIKE(v_conversation_id, '^[A-Za-z0-9._:-]{1,128}$') THEN
RAISE_APPLICATION_ERROR(-20802, 'conversationId contains unsupported characters');
END IF;
v_params := '{"conversation_id":"' || v_conversation_id || '"}';
END IF;
RETURN DBMS_CLOUD_AI_AGENT.RUN_TEAM(
team_name => 'KB_SELECT_AI_ROUTER_TEAM',
user_prompt => p_prompt,
params => v_params
);
END run_team;
END kb_select_ai_router_api;
/
SHOW ERRORS
PROMPT === Granting only the Team API to the ORDS runtime ===
GRANT EXECUTE ON kb_select_ai_router_api TO cb_ords;
PROMPT === POC_2 Select AI Team API ready ===
EXIT

View File

@@ -0,0 +1,143 @@
-- ============================================================
-- 59_kb_select_ai_router_ords.sql
--
-- POST /ords/cb-ords/kb-select-ai-agent/run
-- Authorization: Bearer <VPD token>
-- {"prompt":"...","conversationId":"optional-safe-id"}
--
-- The ORDS runtime validates the bearer and sets the application context
-- before invoking the POC_2-owned Team API. The context is session scoped,
-- so VPD still applies to any SQL action selected by the Select AI tool.
--
-- Execute as CB_ORDS after 22 and 58.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === Resetting only the KB Select AI Agent ORDS module ===
BEGIN
ORDS.DELETE_MODULE(p_module_name => 'kb.select.ai.agent');
EXCEPTION
WHEN OTHERS THEN NULL;
END;
/
PROMPT === Creating KB Select AI Agent ORDS endpoint ===
BEGIN
ORDS.DEFINE_MODULE(
p_module_name => 'kb.select.ai.agent',
p_base_path => 'kb-select-ai-agent/',
p_items_per_page => 0,
p_status => 'PUBLISHED'
);
ORDS.DEFINE_TEMPLATE(
p_module_name => 'kb.select.ai.agent',
p_pattern => 'run'
);
ORDS.DEFINE_HANDLER(
p_module_name => 'kb.select.ai.agent',
p_pattern => 'run',
p_method => 'POST',
p_source_type => ORDS.source_type_plsql,
p_source => q'~
DECLARE
v_body_text CLOB;
v_prompt VARCHAR2(32767);
v_requested_conversation_id VARCHAR2(128);
v_internal_conversation_id VARCHAR2(128);
v_stakeholder_user_id VARCHAR2(4000);
v_answer CLOB;
BEGIN
cb_ords_handler_pkg.set_vpd_context(:auth_header, :probe_id);
-- ORDS exposes request JSON as a stream bind. Read it once only.
v_body_text := :body_text;
v_prompt := JSON_VALUE(v_body_text, '$.prompt' RETURNING VARCHAR2(32767));
v_requested_conversation_id := JSON_VALUE(v_body_text, '$.conversationId' RETURNING VARCHAR2(128));
IF v_prompt IS NULL OR TRIM(v_prompt) IS NULL THEN
RAISE_APPLICATION_ERROR(-20801, 'prompt is required');
END IF;
IF v_requested_conversation_id IS NULL THEN
v_requested_conversation_id := 'kb-' || LOWER(RAWTOHEX(SYS_GUID()));
ELSIF NOT REGEXP_LIKE(v_requested_conversation_id, '^[A-Za-z0-9._:-]{1,128}$') THEN
RAISE_APPLICATION_ERROR(-20802, 'conversationId contains unsupported characters');
END IF;
-- A connection-pooled ORDS session can be reused. Scope the Team's
-- conversation memory to the VPD subject as well as the caller's public ID.
v_stakeholder_user_id := NVL(SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID'), 'anonymous');
v_internal_conversation_id := 'kb-' || LOWER(RAWTOHEX(STANDARD_HASH(
v_stakeholder_user_id || ':' || v_requested_conversation_id,
'SHA256'
)));
v_answer := poc_2.kb_select_ai_router_api.run_team(
p_prompt => v_prompt,
p_conversation_id => v_internal_conversation_id
);
:status_code := 200;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('team', 'KB_SELECT_AI_ROUTER_TEAM');
APEX_JSON.WRITE('conversationId', v_requested_conversation_id);
APEX_JSON.WRITE('answer', v_answer);
APEX_JSON.CLOSE_OBJECT;
cb_ords_handler_pkg.clear_vpd_context;
EXCEPTION
WHEN OTHERS THEN
cb_ords_handler_pkg.clear_vpd_context;
:status_code := CASE
WHEN SQLCODE IN (-20801, -20802) OR SQLCODE BETWEEN -40599 AND -40400 THEN 400
WHEN SQLCODE BETWEEN -20199 AND -20100 THEN 403
ELSE 500
END;
OWA_UTIL.MIME_HEADER('application/json', FALSE);
HTP.P('Cache-Control: no-store');
OWA_UTIL.HTTP_HEADER_CLOSE;
APEX_JSON.OPEN_OBJECT;
APEX_JSON.WRITE('errorCode', SQLCODE);
APEX_JSON.WRITE('error', SQLERRM);
APEX_JSON.CLOSE_OBJECT;
END;
~',
p_items_per_page => 0
);
ORDS.DEFINE_PARAMETER(
p_module_name => 'kb.select.ai.agent',
p_pattern => 'run',
p_method => 'POST',
p_name => 'Authorization',
p_bind_variable_name => 'auth_header',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
ORDS.DEFINE_PARAMETER(
p_module_name => 'kb.select.ai.agent',
p_pattern => 'run',
p_method => 'POST',
p_name => 'X-VPD-Probe-Id',
p_bind_variable_name => 'probe_id',
p_source_type => 'HEADER',
p_param_type => 'STRING',
p_access_method => 'IN'
);
COMMIT;
END;
/
PROMPT === KB Select AI Agent ORDS endpoint ready ===
PROMPT Path: /ords/cb-ords/kb-select-ai-agent/run
EXIT

View File

@@ -17,7 +17,7 @@ public record BackofficeProperties(
public record Token(int maxDays) { public record Token(int maxDays) {
} }
public record Ords(String baseUrl, Duration timeout) { public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) {
} }
public record Ai( public record Ai(

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.config; package com.cloudhandson.vpdbackoffice.config;
import java.time.Duration; import java.time.Duration;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -17,4 +18,14 @@ public class OrdsClientConfig {
.setReadTimeout(timeout) .setReadTimeout(timeout)
.build(); .build();
} }
@Bean
@Qualifier("ordsAgentRestTemplate")
RestTemplate ordsAgentRestTemplate(BackofficeProperties properties) {
Duration timeout = properties.ords().agentTimeout();
return new RestTemplateBuilder()
.setConnectTimeout(timeout)
.setReadTimeout(timeout)
.build();
}
} }

View File

@@ -13,17 +13,23 @@ import org.springframework.stereotype.Service;
@Service @Service
public class McpSseService { 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 McpToolRegistry toolRegistry;
private final OrdsProbeService ordsProbeService; private final OrdsProbeService ordsProbeService;
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public McpSseService( public McpSseService(
McpToolRegistry toolRegistry, McpToolRegistry toolRegistry,
OrdsProbeService ordsProbeService, OrdsProbeService ordsProbeService,
SelectAiAgentOrdsService selectAiAgentOrdsService,
ObjectMapper objectMapper ObjectMapper objectMapper
) { ) {
this.toolRegistry = toolRegistry; this.toolRegistry = toolRegistry;
this.ordsProbeService = ordsProbeService; this.ordsProbeService = ordsProbeService;
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -76,6 +82,7 @@ public class McpSseService {
item.set("inputSchema", inputSchema(tool)); item.set("inputSchema", inputSchema(tool));
tools.add(item); tools.add(item);
} }
tools.add(selectAiRouterTool());
result.set("tools", tools); result.set("tools", tools);
return result; return result;
} }
@@ -116,9 +123,48 @@ public class McpSseService {
return schema; 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) { private ObjectNode toolsCallResult(JsonNode params) {
String toolName = params.path("name").asText(""); String toolName = params.path("name").asText("");
JsonNode arguments = params.path("arguments"); JsonNode arguments = params.path("arguments");
if (SELECT_AI_ROUTER_TOOL.equals(toolName)) {
return selectAiRouterCallResult(arguments);
}
McpToolView tool = findTool(toolName); McpToolView tool = findTool(toolName);
String bearerToken = arguments.path("bearerToken").asText(""); String bearerToken = arguments.path("bearerToken").asText("");
int limit = normalizeLimit(arguments.path("limit").asInt(50)); int limit = normalizeLimit(arguments.path("limit").asInt(50));
@@ -163,6 +209,30 @@ public class McpSseService {
return result; 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) { private McpToolView findTool(String toolName) {
List<McpToolView> tools = toolRegistry.listTools(); List<McpToolView> tools = toolRegistry.listTools();
return tools.stream() return tools.stream()

View File

@@ -27,6 +27,7 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
@@ -84,7 +85,7 @@ public class OrdsProbeService {
ProtectedObjectService protectedObjectService, ProtectedObjectService protectedObjectService,
AuditService auditService, AuditService auditService,
ProbeErrorClassifier errorClassifier, ProbeErrorClassifier errorClassifier,
RestTemplate ordsRestTemplate, @Qualifier("ordsRestTemplate") RestTemplate ordsRestTemplate,
ObjectMapper objectMapper, ObjectMapper objectMapper,
SettingService settingService, SettingService settingService,
JdbcTemplate jdbcTemplate, JdbcTemplate jdbcTemplate,

View File

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

View File

@@ -42,6 +42,7 @@ backoffice:
ords: ords:
base-url: ${BACKOFFICE_ORDS_BASE_URL:https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords} base-url: ${BACKOFFICE_ORDS_BASE_URL:https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords}
timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s timeout: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}s
agent-timeout: ${BACKOFFICE_ORDS_AGENT_TIMEOUT_SECONDS:180}s
metadata-db: metadata-db:
url: ${BACKOFFICE_ORDS_DB_URL:} url: ${BACKOFFICE_ORDS_DB_URL:}
username: ${BACKOFFICE_ORDS_DB_USERNAME:} username: ${BACKOFFICE_ORDS_DB_USERNAME:}

View File

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