refs #702: align HMM MCP contract and settings
This commit is contained in:
@@ -43,7 +43,8 @@ export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
||||
export BACKOFFICE_REMEMBER_ME_ENABLED="false"
|
||||
export BACKOFFICE_REMEMBER_ME_KEY=""
|
||||
export BACKOFFICE_REMEMBER_ME_DAYS="14"
|
||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||
# HMM HR 질의는 HMM MCP/DBMS_CLOUD_AI_AGENT를 사용합니다. 기존 ORDS 운영 기능이 필요할 때만 설정합니다.
|
||||
export BACKOFFICE_ORDS_BASE_URL=""
|
||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
||||
# ADMIN으로 실행 중이면 Handler 생성은 막히고 소스 보기만 사용합니다.
|
||||
|
||||
@@ -56,6 +56,19 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS
|
||||
멱등적으로 준비하고 `CB_VECTOR_*` 조회 호환 뷰를 갱신한다. 기존 물리 `CB_VECTOR_*` 테이블이
|
||||
존재하는 환경은 덮어쓰지 않는다.
|
||||
|
||||
## HMM MCP 및 시스템 설정
|
||||
|
||||
- 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은
|
||||
`https://hmm-mcp.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로
|
||||
도메인 변경 시 화면 소스를 수정하지 않는다.
|
||||
- HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`,
|
||||
`search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`,
|
||||
`HMM_HR_NORMALIZED_DATA_SEARCH`, `HMM_HR_POLICY_SEARCH`만 읽기 전용으로 실행한다.
|
||||
- `ORDS_BASE_URL`은 기존 VPD/ORDS 접근 검증과 Handler 관리용 선택 설정이다. HMM HR 질의 및
|
||||
Agent Factory MCP 호출에는 사용하지 않는다. 비워 두면 레거시 ORDS 기능만 미설정 상태가 된다.
|
||||
- HMM 기동 시 과거 KB 데모의 오사카 ORDS 주소가 정확히 저장된 경우에만 제거한다. 운영자가 별도로
|
||||
지정한 다른 ORDS 주소는 보존한다.
|
||||
|
||||
## 브랜치·배포 기준
|
||||
|
||||
- Smilegate 기준은 `main`이며, HMM 백오피스의 구현·배포·검증은 `hmm-backoffice` 브랜치만 사용한다.
|
||||
|
||||
@@ -8,6 +8,7 @@ public record BackofficeProperties(
|
||||
Security security,
|
||||
Token token,
|
||||
Ords ords,
|
||||
Mcp mcp,
|
||||
Ai ai
|
||||
) {
|
||||
|
||||
@@ -54,6 +55,13 @@ public record BackofficeProperties(
|
||||
public record Ords(String baseUrl, Duration timeout, Duration agentTimeout) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public HMM MCP endpoint used by Agent Factory and by operators who need to inspect the
|
||||
* deployed tool contract. It is deliberately separate from the optional legacy ORDS URL.
|
||||
*/
|
||||
public record Mcp(String publicUrl) {
|
||||
}
|
||||
|
||||
public record Ai(
|
||||
boolean enabled,
|
||||
String provider,
|
||||
|
||||
@@ -10,4 +10,6 @@ public interface SettingMapper {
|
||||
BackofficeSetting findByKey(@Param("settingKey") String settingKey);
|
||||
|
||||
void upsert(@Param("settingKey") String settingKey, @Param("settingValue") String settingValue);
|
||||
|
||||
void deleteByKey(@Param("settingKey") String settingKey);
|
||||
}
|
||||
|
||||
@@ -449,6 +449,12 @@ public class BackofficeSchemaService {
|
||||
}
|
||||
|
||||
private void seedDefaultSettings(List<SchemaActionResult> results) {
|
||||
if (properties.ords().baseUrl() == null || properties.ords().baseUrl().isBlank()) {
|
||||
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "SKIPPED",
|
||||
"HMM 기본 구성은 ORDS를 사용하지 않습니다. 레거시 ORDS 연동이 필요한 경우에만 설정하세요.",
|
||||
SETTINGS_MERGE_SQL));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.update(SETTINGS_MERGE_SQL, properties.ords().baseUrl());
|
||||
results.add(new SchemaActionResult("ORDS_BASE_URL", "SETTING", "MERGED",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
/** Executes a pre-approved HMM DBMS_CLOUD_AI_AGENT tool. */
|
||||
@FunctionalInterface
|
||||
public interface HmmAiAgentToolRunner {
|
||||
|
||||
JsonNode run(String toolName, ObjectNode input);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.SettingMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Removes the retired KB demo ORDS address without touching an operator-supplied ORDS endpoint. */
|
||||
@Component
|
||||
@Order(1)
|
||||
public class HmmRuntimeSettingsInitializer implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HmmRuntimeSettingsInitializer.class);
|
||||
private static final String RETIRED_KB_ORDS_URL =
|
||||
"https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords";
|
||||
|
||||
private final SettingMapper settingMapper;
|
||||
|
||||
public HmmRuntimeSettingsInitializer(SettingMapper settingMapper) {
|
||||
this.settingMapper = settingMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try {
|
||||
BackofficeSetting setting = settingMapper.findByKey(SettingService.ORDS_BASE_URL);
|
||||
if (setting != null && RETIRED_KB_ORDS_URL.equals(normalize(setting.settingValue()))) {
|
||||
settingMapper.deleteByKey(SettingService.ORDS_BASE_URL);
|
||||
log.info("Removed retired KB demo ORDS setting; HMM MCP is the active integration");
|
||||
}
|
||||
} catch (DataAccessException exception) {
|
||||
// Do not prevent the backoffice from starting while its support schema is being prepared.
|
||||
log.warn("Skipped HMM runtime setting migration: {}", exception.getMostSpecificCause().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String normalized = value.trim();
|
||||
while (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import java.sql.Clob;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** JDBC implementation shared with the standalone HMM MCP facade. */
|
||||
@Service
|
||||
public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JdbcHmmAiAgentToolRunner(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode run(String toolName, com.fasterxml.jackson.databind.node.ObjectNode input) {
|
||||
try {
|
||||
String request = objectMapper.writeValueAsString(input);
|
||||
String response = jdbcTemplate.queryForObject("""
|
||||
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?))
|
||||
FROM dual
|
||||
""", (resultSet, rowNum) -> {
|
||||
Clob clob = resultSet.getClob(1);
|
||||
return clob == null ? "" : clob.getSubString(1, (int) clob.length());
|
||||
}, toolName, request);
|
||||
if (response == null || response.isBlank()) {
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(response);
|
||||
} catch (Exception ignored) {
|
||||
return TextNode.valueOf(response);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw new AppException("HMM AI Agent Tool 실행에 실패했습니다: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,30 +8,39 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** MCP boundary exposing only the row-access-aware GPT-5.4-mini Select AI query tool. */
|
||||
/** Exposes the same read-only HMM HR tool contract as hmm-mcp.cloud-handson.com. */
|
||||
@Service
|
||||
public class McpSseService {
|
||||
|
||||
private static final String SELECT_AI_VPD_QUERY_TOOL = "ords.query.kb_select_ai_vpd";
|
||||
private static final String SELECT_AI_VPD_QUERY_PATH = "cb-ords/kb-select-ai-vpd/query";
|
||||
private static final String SELECT_AI_VPD_QUERY_PROFILE =
|
||||
"KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1";
|
||||
private static final McpToolView SELECT_AI_VPD_QUERY_VIEW = new McpToolView(
|
||||
SELECT_AI_VPD_QUERY_TOOL,
|
||||
"GPT-5.4-mini Select AI 자연어 질의를 행 접근 컨텍스트로 실행합니다. 테이블/컬럼 comment, annotation, constraint 메타데이터를 사용하고 생성 SQL은 KB 업무 테이블의 읽기 전용 SELECT/WITH만 허용합니다.",
|
||||
-1L,
|
||||
"KB Select AI 행 접근 자연어 조회",
|
||||
SELECT_AI_VPD_QUERY_PATH
|
||||
private static final List<ToolSpec> HMM_TOOLS = List.of(
|
||||
new ToolSpec(
|
||||
"resolve_hr_term",
|
||||
"HMM_HR_TERM_RESOLVER",
|
||||
"term",
|
||||
"P_TERM",
|
||||
"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.",
|
||||
"HMM HR 용어 표준화"),
|
||||
new ToolSpec(
|
||||
"search_hr_data",
|
||||
"HMM_HR_NORMALIZED_DATA_SEARCH",
|
||||
"query",
|
||||
"P_QUERY",
|
||||
"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.",
|
||||
"HMM HR 데이터 조회"),
|
||||
new ToolSpec(
|
||||
"search_hr_policy",
|
||||
"HMM_HR_POLICY_SEARCH",
|
||||
"query",
|
||||
"P_QUERY",
|
||||
"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.",
|
||||
"HMM HR 규정 검색")
|
||||
);
|
||||
|
||||
private final SelectAiAgentOrdsService selectAiAgentOrdsService;
|
||||
private final HmmAiAgentToolRunner agentToolRunner;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public McpSseService(
|
||||
SelectAiAgentOrdsService selectAiAgentOrdsService,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.selectAiAgentOrdsService = selectAiAgentOrdsService;
|
||||
public McpSseService(HmmAiAgentToolRunner agentToolRunner, ObjectMapper objectMapper) {
|
||||
this.agentToolRunner = agentToolRunner;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -40,9 +49,10 @@ public class McpSseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP bearer token is the business-user subject token; no separate MCP token is used.
|
||||
* Backoffice authentication protects this compatibility endpoint. The public
|
||||
* HMM MCP endpoint performs its own fixed Bearer-token validation in Nginx.
|
||||
*/
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String vpdBearerToken) {
|
||||
public ObjectNode handle(String contextPath, JsonNode request, String ignoredAuthorization) {
|
||||
ObjectNode response = objectMapper.createObjectNode();
|
||||
response.put("jsonrpc", "2.0");
|
||||
if (request != null && request.has("id")) {
|
||||
@@ -56,30 +66,32 @@ public class McpSseService {
|
||||
case "initialize" -> initializeResult(contextPath);
|
||||
case "notifications/initialized" -> objectMapper.createObjectNode();
|
||||
case "tools/list" -> toolsListResult();
|
||||
case "tools/call" -> toolsCallResult(parameters, vpdBearerToken);
|
||||
case "tools/call" -> toolsCallResult(parameters);
|
||||
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
} catch (Exception exception) {
|
||||
response.remove("result");
|
||||
ObjectNode error = objectMapper.createObjectNode();
|
||||
error.put("code", -32000);
|
||||
error.put("message", e.getMessage());
|
||||
error.put("message", exception.getMessage());
|
||||
response.set("error", error);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** The only tool registered by this MCP server. */
|
||||
public List<McpToolView> registeredTools() {
|
||||
return List.of(SELECT_AI_VPD_QUERY_VIEW);
|
||||
return HMM_TOOLS.stream()
|
||||
.map(tool -> new McpToolView(
|
||||
tool.name(), tool.description(), -1L, tool.displayName(), tool.agentToolName()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ObjectNode initializeResult(String contextPath) {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
result.put("protocolVersion", "2024-11-05");
|
||||
ObjectNode serverInfo = objectMapper.createObjectNode();
|
||||
serverInfo.put("name", "vpd-ords-backoffice-" + contextPath);
|
||||
serverInfo.put("version", "0.1.0");
|
||||
serverInfo.put("name", "hmm-hr-backoffice-" + contextPath);
|
||||
serverInfo.put("version", "1.0.0");
|
||||
result.set("serverInfo", serverInfo);
|
||||
ObjectNode capabilities = objectMapper.createObjectNode();
|
||||
capabilities.set("tools", objectMapper.createObjectNode());
|
||||
@@ -90,70 +102,50 @@ public class McpSseService {
|
||||
private ObjectNode toolsListResult() {
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode tools = objectMapper.createArrayNode();
|
||||
tools.add(selectAiVpdQueryTool());
|
||||
HMM_TOOLS.forEach(tool -> tools.add(toolDefinition(tool)));
|
||||
result.set("tools", tools);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode selectAiVpdQueryTool() {
|
||||
private ObjectNode toolDefinition(ToolSpec tool) {
|
||||
ObjectNode item = objectMapper.createObjectNode();
|
||||
item.put("name", SELECT_AI_VPD_QUERY_TOOL);
|
||||
item.put("description", SELECT_AI_VPD_QUERY_VIEW.description());
|
||||
|
||||
item.put("name", tool.name());
|
||||
item.put("description", tool.description());
|
||||
ObjectNode schema = objectMapper.createObjectNode();
|
||||
schema.put("type", "object");
|
||||
ObjectNode properties = objectMapper.createObjectNode();
|
||||
|
||||
ObjectNode prompt = objectMapper.createObjectNode();
|
||||
prompt.put("type", "string");
|
||||
prompt.put("description", "KB 업무 원장에 대해 조회할 내용을 자연어로 입력합니다.");
|
||||
prompt.put("maxLength", 4000);
|
||||
properties.set("prompt", prompt);
|
||||
|
||||
ObjectNode limit = objectMapper.createObjectNode();
|
||||
limit.put("type", "integer");
|
||||
limit.put("description", "최대 반환 행 수. 1부터 100까지 허용하며 기본값은 50입니다.");
|
||||
limit.put("minimum", 1);
|
||||
limit.put("maximum", 100);
|
||||
properties.set("limit", limit);
|
||||
|
||||
ObjectNode argument = objectMapper.createObjectNode();
|
||||
argument.put("type", "string");
|
||||
argument.put("description", tool.argumentDescription());
|
||||
argument.put("maxLength", 4000);
|
||||
properties.set(tool.argumentName(), argument);
|
||||
schema.set("properties", properties);
|
||||
ArrayNode required = objectMapper.createArrayNode();
|
||||
required.add("prompt");
|
||||
required.add(tool.argumentName());
|
||||
schema.set("required", required);
|
||||
schema.put("additionalProperties", false);
|
||||
item.set("inputSchema", schema);
|
||||
return item;
|
||||
}
|
||||
|
||||
private ObjectNode toolsCallResult(JsonNode params, String vpdBearerToken) {
|
||||
String toolName = params.path("name").asText("");
|
||||
if (!SELECT_AI_VPD_QUERY_TOOL.equals(toolName)) {
|
||||
throw new AppException("등록되지 않은 MCP tool입니다: " + toolName);
|
||||
}
|
||||
|
||||
JsonNode arguments = params.path("arguments");
|
||||
String token = vpdBearerToken == null ? "" : vpdBearerToken.trim();
|
||||
if (token.isBlank()) {
|
||||
return tokenAccessDeniedResult();
|
||||
}
|
||||
JsonNode response;
|
||||
try {
|
||||
response = selectAiAgentOrdsService.run(
|
||||
token,
|
||||
arguments.path("prompt").asText(""),
|
||||
normalizeLimit(arguments.path("limit").asInt(50))
|
||||
);
|
||||
} catch (VpdTokenAccessDeniedException ignored) {
|
||||
return tokenAccessDeniedResult();
|
||||
private ObjectNode toolsCallResult(JsonNode params) {
|
||||
String requestedName = params.path("name").asText("");
|
||||
ToolSpec tool = HMM_TOOLS.stream()
|
||||
.filter(candidate -> candidate.name().equals(requestedName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AppException("등록되지 않은 HMM MCP tool입니다: " + requestedName));
|
||||
String argument = params.path("arguments").path(tool.argumentName()).asText("").trim();
|
||||
if (argument.isBlank()) {
|
||||
throw new AppException(tool.argumentName() + " 입력값은 비워둘 수 없습니다.");
|
||||
}
|
||||
ObjectNode input = objectMapper.createObjectNode();
|
||||
input.put(tool.agentParameterName(), argument);
|
||||
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input);
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("toolName", SELECT_AI_VPD_QUERY_TOOL);
|
||||
payload.put("profile", SELECT_AI_VPD_QUERY_PROFILE);
|
||||
payload.put("ordsPath", SELECT_AI_VPD_QUERY_PATH);
|
||||
payload.set("response", response);
|
||||
|
||||
payload.put("toolName", tool.name());
|
||||
payload.put("agentTool", tool.agentToolName());
|
||||
payload.set("response", toolResponse);
|
||||
ObjectNode result = objectMapper.createObjectNode();
|
||||
ArrayNode content = objectMapper.createArrayNode();
|
||||
ObjectNode text = objectMapper.createObjectNode();
|
||||
@@ -165,34 +157,26 @@ public class McpSseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private ObjectNode tokenAccessDeniedResult() {
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("status", "VPD_TOKEN_DENIED");
|
||||
payload.put("message", "토큰이 없거나 유효하지 않아 이 요청을 수행할 권한이 없습니다.");
|
||||
|
||||
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", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
private int normalizeLimit(int limit) {
|
||||
if (limit < 1) {
|
||||
return 50;
|
||||
}
|
||||
return Math.min(limit, 100);
|
||||
}
|
||||
|
||||
private String pretty(Object value) {
|
||||
try {
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
} catch (Exception exception) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private record ToolSpec(
|
||||
String name,
|
||||
String agentToolName,
|
||||
String argumentName,
|
||||
String agentParameterName,
|
||||
String description,
|
||||
String displayName
|
||||
) {
|
||||
String argumentDescription() {
|
||||
return "term".equals(argumentName)
|
||||
? "확인할 휴가·근태 용어, 동의어 또는 코드입니다."
|
||||
: "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ public class SettingService {
|
||||
@Transactional
|
||||
public void updateOrdsBaseUrl(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new AppException("ORDS Base URL은 필수입니다.");
|
||||
settingMapper.deleteByKey(ORDS_BASE_URL);
|
||||
return;
|
||||
}
|
||||
String normalized = normalizeUrl(value);
|
||||
if (!normalized.startsWith("https://") && !normalized.startsWith("http://")) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.mcp.McpReasoningCommand;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
@@ -23,19 +24,22 @@ public class McpReasoningController {
|
||||
private final McpReasoningService reasoningService;
|
||||
private final BearerTokenService tokenService;
|
||||
private final UserMapper userMapper;
|
||||
private final BackofficeProperties properties;
|
||||
|
||||
public McpReasoningController(
|
||||
McpToolRegistry toolRegistry,
|
||||
McpSseService mcpSseService,
|
||||
McpReasoningService reasoningService,
|
||||
BearerTokenService tokenService,
|
||||
UserMapper userMapper
|
||||
UserMapper userMapper,
|
||||
BackofficeProperties properties
|
||||
) {
|
||||
this.toolRegistry = toolRegistry;
|
||||
this.mcpSseService = mcpSseService;
|
||||
this.reasoningService = reasoningService;
|
||||
this.tokenService = tokenService;
|
||||
this.userMapper = userMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@GetMapping("/mcp-reasoning")
|
||||
@@ -61,6 +65,7 @@ public class McpReasoningController {
|
||||
@GetMapping("/mcp-sse")
|
||||
public String ssePage(Model model) {
|
||||
model.addAttribute("tools", mcpSseService.registeredTools());
|
||||
model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl());
|
||||
return "mcp-sse";
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.BackofficeSchemaService;
|
||||
import com.cloudhandson.vpdbackoffice.service.SettingService;
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
@@ -15,14 +16,21 @@ public class SettingController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final BackofficeSchemaService backofficeSchemaService;
|
||||
private final BackofficeProperties properties;
|
||||
|
||||
public SettingController(SettingService settingService, BackofficeSchemaService backofficeSchemaService) {
|
||||
public SettingController(
|
||||
SettingService settingService,
|
||||
BackofficeSchemaService backofficeSchemaService,
|
||||
BackofficeProperties properties
|
||||
) {
|
||||
this.settingService = settingService;
|
||||
this.backofficeSchemaService = backofficeSchemaService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@GetMapping("/settings")
|
||||
public String settings(Model model) {
|
||||
model.addAttribute("hmmMcpPublicUrl", properties.mcp().publicUrl());
|
||||
try {
|
||||
model.addAttribute("ordsBaseUrl", settingService.ordsBaseUrl());
|
||||
} catch (DataAccessException exception) {
|
||||
@@ -52,7 +60,9 @@ public class SettingController {
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
settingService.updateOrdsBaseUrl(ordsBaseUrl);
|
||||
redirectAttributes.addFlashAttribute("message", "ORDS 설정을 저장했습니다.");
|
||||
redirectAttributes.addFlashAttribute("message", ordsBaseUrl == null || ordsBaseUrl.isBlank()
|
||||
? "레거시 ORDS 설정을 해제했습니다. HMM MCP 및 HR 질의에는 영향이 없습니다."
|
||||
: "레거시 ORDS 설정을 저장했습니다.");
|
||||
return "redirect:/settings";
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,17 @@ backoffice:
|
||||
token:
|
||||
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
||||
ords:
|
||||
base-url: ${BACKOFFICE_ORDS_BASE_URL:https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords}
|
||||
# HMM HR agent queries use DBMS_CLOUD_AI_AGENT through HMM MCP, not ORDS.
|
||||
# Keep this empty unless an operator explicitly enables a legacy ORDS operation.
|
||||
base-url: ${BACKOFFICE_ORDS_BASE_URL:}
|
||||
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:}
|
||||
password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
|
||||
mcp:
|
||||
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-mcp.cloud-handson.com/mcp}
|
||||
ai:
|
||||
enabled: ${BACKOFFICE_AI_ENABLED:false}
|
||||
provider: ${BACKOFFICE_AI_PROVIDER:openai}
|
||||
|
||||
@@ -22,4 +22,9 @@
|
||||
WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at)
|
||||
VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP)
|
||||
</update>
|
||||
|
||||
<delete id="deleteByKey">
|
||||
DELETE FROM cb_backoffice_setting
|
||||
WHERE setting_key = #{settingKey,jdbcType=VARCHAR}
|
||||
</delete>
|
||||
</mapper>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>MCP 연동</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하고, DB에서는 토큰 기준 행 접근 context가 적용됩니다.</p>
|
||||
<p>HMM HR MCP는 표준 용어 변환, HR 데이터 조회, HR 규정 PDF 검색의 세 도구를 제공합니다. 모든 도구는 읽기 전용이며, 휴가·근태 표현이 모호하면 용어 변환을 먼저 사용합니다.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -21,19 +21,19 @@
|
||||
<h2>서비스 구성</h2>
|
||||
<div class="mcp-service-grid">
|
||||
<div class="mcp-service-item">
|
||||
<span>기본 서비스</span>
|
||||
<strong><code>/mcp/sse</code></strong>
|
||||
<small>기존 MCP client 설정과 호환됩니다.</small>
|
||||
<span>공개 MCP Endpoint</span>
|
||||
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></strong>
|
||||
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
|
||||
</div>
|
||||
<div class="mcp-service-item">
|
||||
<span>Context 서비스</span>
|
||||
<strong><code>/mcp/{contextPath}/sse</code></strong>
|
||||
<small>업무/환경별 이름으로 같은 ORDS tool set을 분리 노출합니다.</small>
|
||||
<span>인증</span>
|
||||
<strong><code>Authorization: Bearer <HMM MCP Token></code></strong>
|
||||
<small>고정 MCP 서버 토큰으로 인증합니다. DB 비밀번호와 Wallet은 전달하지 않습니다.</small>
|
||||
</div>
|
||||
<div class="mcp-service-item">
|
||||
<span>예시</span>
|
||||
<strong><code>/mcp/vpd-live/sse</code></strong>
|
||||
<small>SSE 연결 시 message endpoint가 같은 context로 반환됩니다.</small>
|
||||
<span>도구 수</span>
|
||||
<strong>3개</strong>
|
||||
<small>용어 표준화 → HR 데이터/규정 검색 순서로 사용합니다.</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -46,28 +46,20 @@
|
||||
<table class="table align-middle">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Default SSE</th>
|
||||
<td><code>/mcp/sse</code></td>
|
||||
<th>HMM MCP</th>
|
||||
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Default Message</th>
|
||||
<td><code>/mcp/messages?sessionId={sessionId}</code></td>
|
||||
<th>Transport</th>
|
||||
<td>Streamable HTTP</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Context SSE</th>
|
||||
<td><code>/mcp/{contextPath}/sse</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Context Message</th>
|
||||
<td><code>/mcp/{contextPath}/messages?sessionId={sessionId}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Context Path</th>
|
||||
<td>영문/숫자로 시작하고 영문/숫자/<code>_</code>/<code>-</code>만 사용합니다. 예: <code>vpd</code>, <code>ords-prod</code></td>
|
||||
<th>백오피스 호환 Endpoint</th>
|
||||
<td><code>/mcp</code> — 로그인 세션에서 같은 HMM 도구 계약을 확인합니다.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Auth</th>
|
||||
<td><code>Authorization: Bearer <사용자 Bearer Token></code> — 이 토큰 하나로 행 접근 컨텍스트를 설정합니다.</td>
|
||||
<td><code>Authorization: Bearer <HMM MCP Token></code> — 공개 MCP 서버의 고정 인증 토큰입니다.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Methods</th>
|
||||
@@ -87,7 +79,7 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Object</th>
|
||||
<th>ORDS Path</th>
|
||||
<th>Agent Tool</th>
|
||||
<th>Instruction / parameter mapping</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -95,11 +87,11 @@
|
||||
<tr th:each="tool : ${tools}">
|
||||
<td><code th:text="${tool.name()}">ords.query.admin.board_posts</code></td>
|
||||
<td th:text="${tool.displayName()}">ADMIN.BOARD_POSTS</td>
|
||||
<td><code th:text="${tool.ordsPath()}">cb-ords/cb-object-query/admin/board_posts</code></td>
|
||||
<td><code th:text="${tool.ordsPath()}">HMM_HR_TERM_RESOLVER</code></td>
|
||||
<td>
|
||||
<div th:text="${tool.description()}">ORDS 행 접근 조회 도구 설명</div>
|
||||
<small class="text-muted">
|
||||
HTTP <code>Authorization</code> → 행 접근 컨텍스트 · <code>prompt</code> → GPT-5.4-mini Select AI 자연어 질의 · <code>limit</code> → 최대 반환 행 수
|
||||
<code>resolve_hr_term.term</code> → 표준 용어·코드 · <code>search_hr_data.query</code> → HR 데이터 · <code>search_hr_policy.query</code> → 규정 PDF 검색
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -116,10 +108,9 @@
|
||||
<summary>tools/call parameter 예시 보기</summary>
|
||||
<h2>tools/call Arguments</h2>
|
||||
<pre class="code-block">{
|
||||
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
|
||||
"limit": 50
|
||||
"term": "연차 이월"
|
||||
}</pre>
|
||||
<p class="form-hint">등록 tool은 <code>ords.query.kb_select_ai_vpd</code> 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.</p>
|
||||
<p class="form-hint">등록 도구는 <code>resolve_hr_term</code>, <code>search_hr_data</code>, <code>search_hr_policy</code>입니다. 표준 용어가 필요한 질문은 <code>resolve_hr_term</code> 결과를 사용해 데이터 또는 규정 검색을 이어갑니다.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -153,10 +144,9 @@
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "ords.query.kb_select_ai_vpd",
|
||||
"name": "search_hr_policy",
|
||||
"arguments": {
|
||||
"prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
|
||||
"limit": 50
|
||||
"query": "연차 휴가 이월 기준과 제한을 알려줘."
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>시스템 설정</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>여기서는 ORDS Base URL만 저장합니다. VPD runtime, grant, 지원 테이블 생성처럼 DB에 영향을 주는 작업은 <strong>DB 준비 상태</strong>에서 별도로 확인·실행합니다.</p>
|
||||
<p>HMM HR 질의와 Agent Factory는 HMM MCP를 사용합니다. ORDS는 기존 VPD/ORDS 운영 기능이 필요한 경우에만 별도로 설정합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -22,18 +22,33 @@
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="architecture-kicker">ORDS 연결</span>
|
||||
<span class="architecture-kicker">HMM MCP · 현재 사용</span>
|
||||
<h2>HMM HR Agent 도구</h2>
|
||||
<p class="section-subtitle">용어 정규화, HR 데이터 조회, HR 정책 문서 검색은 이 MCP의 DBMS_CLOUD_AI_AGENT 도구를 사용합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="span-2">
|
||||
Public MCP endpoint
|
||||
<input class="form-control" th:value="${hmmMcpPublicUrl}" readonly aria-readonly="true">
|
||||
</label>
|
||||
<p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></p>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="architecture-kicker">선택형 레거시 연동</span>
|
||||
<h2>ORDS Base URL</h2>
|
||||
<p class="section-subtitle">저장 후 접근 검증과 조회 연동이 이 주소를 사용합니다.</p>
|
||||
<p class="section-subtitle">기존 VPD/ORDS 접근 검증과 ORDS Handler 운영에만 사용합니다. 비워 두면 해당 기능은 미설정 상태로 표시되며 HMM HR 질의에는 영향이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/settings/ords" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label class="span-2">
|
||||
Base URL
|
||||
<input class="form-control" name="ordsBaseUrl" th:value="${ordsBaseUrl}" required inputmode="url" autocomplete="url">
|
||||
Legacy ORDS Base URL
|
||||
<input class="form-control" name="ordsBaseUrl" th:value="${ordsBaseUrl}" inputmode="url" autocomplete="url" placeholder="설정하지 않음">
|
||||
</label>
|
||||
<button class="btn rw-btn-primary" type="submit">연결 주소 저장</button>
|
||||
<button class="btn rw-btn-secondary" type="submit">레거시 ORDS 설정 저장</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.setting.BackofficeSetting;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.SettingMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.boot.DefaultApplicationArguments;
|
||||
|
||||
class HmmRuntimeSettingsInitializerTest {
|
||||
|
||||
@Test
|
||||
void removesOnlyTheRetiredKbOrdsSetting() {
|
||||
SettingMapper mapper = Mockito.mock(SettingMapper.class);
|
||||
when(mapper.findByKey(SettingService.ORDS_BASE_URL)).thenReturn(new BackofficeSetting(
|
||||
SettingService.ORDS_BASE_URL,
|
||||
"https://g329127dfd380ad-kbaipoc.adb.ap-osaka-1.oraclecloudapps.com/ords/"));
|
||||
|
||||
new HmmRuntimeSettingsInitializer(mapper).run(new DefaultApplicationArguments());
|
||||
|
||||
verify(mapper).deleteByKey(SettingService.ORDS_BASE_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAnOperatorConfiguredLegacyOrdsSetting() {
|
||||
SettingMapper mapper = Mockito.mock(SettingMapper.class);
|
||||
when(mapper.findByKey(SettingService.ORDS_BASE_URL)).thenReturn(new BackofficeSetting(
|
||||
SettingService.ORDS_BASE_URL, "https://ords.hmm.example/ords"));
|
||||
|
||||
new HmmRuntimeSettingsInitializer(mapper).run(new DefaultApplicationArguments());
|
||||
|
||||
verify(mapper, never()).deleteByKey(SettingService.ORDS_BASE_URL);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,67 @@
|
||||
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.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
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(
|
||||
selectAiAgentOrdsService,
|
||||
objectMapper
|
||||
);
|
||||
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
|
||||
private final McpSseService service = new McpSseService(agentToolRunner, objectMapper);
|
||||
|
||||
@Test
|
||||
void listsOnlyVpdSelectAiToolWithPromptInput() {
|
||||
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
|
||||
ObjectNode response = service.handle("default", request(1, "tools/list"));
|
||||
|
||||
var tools = response.path("result").path("tools");
|
||||
assertThat(tools).hasSize(1);
|
||||
var selectAi = tools.get(0);
|
||||
assertThat(selectAi.path("name").asText()).isEqualTo("ords.query.kb_select_ai_vpd");
|
||||
assertThat(selectAi.path("inputSchema").path("required"))
|
||||
.extracting(node -> node.asText())
|
||||
.contains("prompt");
|
||||
assertThat(selectAi.path("inputSchema").path("properties").has("bearerToken")).isFalse();
|
||||
assertThat(selectAi.path("inputSchema").path("properties").has("limit")).isTrue();
|
||||
assertThat(selectAi.path("inputSchema").path("properties").has("conversationId")).isFalse();
|
||||
assertThat(tools).hasSize(3);
|
||||
assertThat(tools).extracting(node -> node.path("name").asText())
|
||||
.containsExactly("resolve_hr_term", "search_hr_data", "search_hr_policy");
|
||||
assertThat(tools.get(0).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("term");
|
||||
assertThat(tools.get(1).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("query");
|
||||
assertThat(tools.get(2).path("inputSchema").path("required"))
|
||||
.extracting(JsonNode::asText)
|
||||
.containsExactly("query");
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsVpdSelectAiThroughOrdsService() {
|
||||
void callsHMMTermResolverWithTheApprovedAgentToolAndInputName() {
|
||||
ObjectNode request = request(2, "tools/call");
|
||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||
params.put("name", "ords.query.kb_select_ai_vpd");
|
||||
ObjectNode arguments = params.putObject("arguments");
|
||||
arguments.put("prompt", "고객 수를 조회해 줘");
|
||||
arguments.put("limit", 25);
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "resolve_hr_term");
|
||||
params.putObject("arguments").put("term", "연차 이월");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "user-bearer");
|
||||
ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session");
|
||||
|
||||
CapturingSelectAiAgentOrdsService agentService =
|
||||
(CapturingSelectAiAgentOrdsService) selectAiAgentOrdsService;
|
||||
assertThat(agentService.bearerToken).isEqualTo("user-bearer");
|
||||
assertThat(agentService.prompt).isEqualTo("고객 수를 조회해 줘");
|
||||
assertThat(agentService.limit).isEqualTo(25);
|
||||
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
|
||||
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
|
||||
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_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
|
||||
.contains("SELECT COUNT(*) FROM KB_CUSTOMERS");
|
||||
.contains("resolve_hr_term")
|
||||
.contains("HMM_HR_TERM_RESOLVER")
|
||||
.contains("ANNUAL_LEAVE_CARRYOVER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsToolLevelDeniedResultWhenVpdTokenIsMissing() {
|
||||
void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
|
||||
ObjectNode request = request(3, "tools/call");
|
||||
ObjectNode params = (ObjectNode) request.putObject("params");
|
||||
ObjectNode params = request.putObject("params");
|
||||
params.put("name", "ords.query.kb_select_ai_vpd");
|
||||
params.putObject("arguments").put("prompt", "고객 수를 조회해 줘");
|
||||
params.putObject("arguments").put("prompt", "legacy query");
|
||||
|
||||
ObjectNode response = service.handle("default", request, "");
|
||||
ObjectNode response = service.handle("default", request);
|
||||
|
||||
assertThat(response.path("error").isMissingNode()).isTrue();
|
||||
assertThat(response.path("result").path("isError").asBoolean()).isTrue();
|
||||
assertThat(response.path("result").path("content").get(0).path("text").asText())
|
||||
.contains("VPD_TOKEN_DENIED")
|
||||
.contains("권한이 없습니다");
|
||||
assertThat(response.path("result").isMissingNode()).isTrue();
|
||||
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
|
||||
}
|
||||
|
||||
private ObjectNode request(int id, String method) {
|
||||
@@ -78,24 +72,18 @@ class McpSseServiceTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private static final class CapturingSelectAiAgentOrdsService extends SelectAiAgentOrdsService {
|
||||
private final class CapturingHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
|
||||
|
||||
private String bearerToken;
|
||||
private String prompt;
|
||||
private int limit;
|
||||
|
||||
private CapturingSelectAiAgentOrdsService() {
|
||||
super(null, null, new ObjectMapper());
|
||||
}
|
||||
private String toolName;
|
||||
private ObjectNode input;
|
||||
|
||||
@Override
|
||||
public JsonNode run(String bearerToken, String prompt, int limit) {
|
||||
this.bearerToken = bearerToken;
|
||||
this.prompt = prompt;
|
||||
this.limit = limit;
|
||||
return new ObjectMapper().createObjectNode()
|
||||
.put("profile", "KB_AIDP_SELECTAI_GPT54_MINI_FULLMETA_PROFILE_V1")
|
||||
.put("generatedSql", "SELECT COUNT(*) FROM KB_CUSTOMERS");
|
||||
public JsonNode run(String requestedToolName, ObjectNode requestedInput) {
|
||||
toolName = requestedToolName;
|
||||
input = requestedInput.deepCopy();
|
||||
return objectMapper.createObjectNode()
|
||||
.put("termCode", "ANNUAL_LEAVE_CARRYOVER")
|
||||
.put("termName", "연차 이월");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,12 @@ class GuidedFlowTemplateTest {
|
||||
assertThat(chatbot).contains("검증 세션 사용자").contains("태그 벡터 검색");
|
||||
assertThat(reasoning).contains("MCP tool을 고르고").contains("검증 세션 사용자").contains("MCP Tool");
|
||||
assertThat(client).contains("tool 선택과 호출은 reasoning 결과").doesNotContain("Context Path");
|
||||
assertThat(sse).contains("Instruction / parameter mapping").contains("Authorization");
|
||||
assertThat(sse)
|
||||
.contains("Instruction / parameter mapping")
|
||||
.contains("resolve_hr_term")
|
||||
.contains("search_hr_data")
|
||||
.contains("search_hr_policy")
|
||||
.doesNotContain("kb_select_ai_vpd", "KB_CLAIMS");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -24,13 +24,17 @@ class SettingsTemplateRenderTest {
|
||||
var context = new Context(Locale.KOREAN);
|
||||
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||
context.setVariable("ordsBaseUrl", "https://ords.example.test/ords");
|
||||
context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp");
|
||||
|
||||
String connection = engine.process("settings", context);
|
||||
String database = engine.process("settings-database", context);
|
||||
|
||||
assertThat(connection)
|
||||
.contains("ORDS Base URL")
|
||||
.contains("HMM HR Agent 도구")
|
||||
.contains("https://hmm-mcp.cloud-handson.com/mcp")
|
||||
.contains("Legacy ORDS Base URL")
|
||||
.contains("/settings/database")
|
||||
.doesNotContain("g329127dfd380ad-kbaipoc")
|
||||
.doesNotContain("/settings/database/initialize");
|
||||
assertThat(database)
|
||||
.contains("DB 준비 상태")
|
||||
|
||||
Reference in New Issue
Block a user