diff --git a/.env.example b/.env.example index ca57789..f108310 100644 --- a/.env.example +++ b/.env.example @@ -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 생성은 막히고 소스 보기만 사용합니다. diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index b3f9325..43e7439 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -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` 브랜치만 사용한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index a959001..6d97e11 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -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, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java index 572b70a..3d661ee 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/SettingMapper.java @@ -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); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index 2712457..9f5d324 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -449,6 +449,12 @@ public class BackofficeSchemaService { } private void seedDefaultSettings(List 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", diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java new file mode 100644 index 0000000..5acc154 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmAiAgentToolRunner.java @@ -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); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java new file mode 100644 index 0000000..7d4c110 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializer.java @@ -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; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java new file mode 100644 index 0000000..8a8264f --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/JdbcHmmAiAgentToolRunner.java @@ -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()); + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java index 1765adf..0645b4e 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpSseService.java @@ -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 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 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) + ? "확인할 휴가·근태 용어, 동의어 또는 코드입니다." + : "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다."; + } + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java index 888e047..2718226 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SettingService.java @@ -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://")) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java index 0b83085..efff248 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpReasoningController.java @@ -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"; } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java index c53a0d9..2206745 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SettingController.java @@ -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"; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5728558..4bed6d3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/main/resources/mapper/SettingMapper.xml b/src/main/resources/mapper/SettingMapper.xml index fe422a0..45882b4 100644 --- a/src/main/resources/mapper/SettingMapper.xml +++ b/src/main/resources/mapper/SettingMapper.xml @@ -22,4 +22,9 @@ WHEN NOT MATCHED THEN INSERT (setting_key, setting_value, updated_at) VALUES (src.setting_key, src.setting_value, SYSTIMESTAMP) + + + DELETE FROM cb_backoffice_setting + WHERE setting_key = #{settingKey,jdbcType=VARCHAR} + diff --git a/src/main/resources/templates/mcp-sse.html b/src/main/resources/templates/mcp-sse.html index 445bee2..ea4c659 100644 --- a/src/main/resources/templates/mcp-sse.html +++ b/src/main/resources/templates/mcp-sse.html @@ -8,7 +8,7 @@

MCP 연동

도움말 -

사용자 Bearer Token을 전달해 GPT-5.4-mini Select AI 자연어 조회를 실행하는 단일 MCP tool을 제공합니다. Select AI는 comment, annotation, constraint 메타데이터를 함께 사용하고, DB에서는 토큰 기준 행 접근 context가 적용됩니다.

+

HMM HR MCP는 표준 용어 변환, HR 데이터 조회, HR 규정 PDF 검색의 세 도구를 제공합니다. 모든 도구는 읽기 전용이며, 휴가·근태 표현이 모호하면 용어 변환을 먼저 사용합니다.

@@ -21,19 +21,19 @@

서비스 구성

- 기본 서비스 - /mcp/sse - 기존 MCP client 설정과 호환됩니다. + 공개 MCP Endpoint + https://hmm-mcp.cloud-handson.com/mcp + Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.
- Context 서비스 - /mcp/{contextPath}/sse - 업무/환경별 이름으로 같은 ORDS tool set을 분리 노출합니다. + 인증 + Authorization: Bearer <HMM MCP Token> + 고정 MCP 서버 토큰으로 인증합니다. DB 비밀번호와 Wallet은 전달하지 않습니다.
- 예시 - /mcp/vpd-live/sse - SSE 연결 시 message endpoint가 같은 context로 반환됩니다. + 도구 수 + 3개 + 용어 표준화 → HR 데이터/규정 검색 순서로 사용합니다.
@@ -46,28 +46,20 @@ - - + + - - + + - - - - - - - - - - + + - + @@ -87,7 +79,7 @@ - + @@ -95,11 +87,11 @@ - + @@ -116,10 +108,9 @@ tools/call parameter 예시 보기

tools/call Arguments

{
-  "prompt": "KB_CLAIMS의 전체 청구 건수를 조회해 줘.",
-  "limit": 50
+  "term": "연차 이월"
 }
-

등록 tool은 ords.query.kb_select_ai_vpd 하나입니다. HTTP Authorization의 사용자 Bearer Token으로 컨텍스트를 설정한 뒤 GPT-5.4-mini가 comment, annotation, constraint를 참고해 생성한 검증된 읽기 전용 KB 원장 SQL만 실행합니다.

+

등록 도구는 resolve_hr_term, search_hr_data, search_hr_policy입니다. 표준 용어가 필요한 질문은 resolve_hr_term 결과를 사용해 데이터 또는 규정 검색을 이어갑니다.

@@ -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": "연차 휴가 이월 기준과 제한을 알려줘." } } } diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 2a170e5..5fa9230 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -8,7 +8,7 @@

시스템 설정

도움말 -

여기서는 ORDS Base URL만 저장합니다. VPD runtime, grant, 지원 테이블 생성처럼 DB에 영향을 주는 작업은 DB 준비 상태에서 별도로 확인·실행합니다.

+

HMM HR 질의와 Agent Factory는 HMM MCP를 사용합니다. ORDS는 기존 VPD/ORDS 운영 기능이 필요한 경우에만 별도로 설정합니다.

@@ -22,18 +22,33 @@
- ORDS 연결 + HMM MCP · 현재 사용 +

HMM HR Agent 도구

+

용어 정규화, HR 데이터 조회, HR 정책 문서 검색은 이 MCP의 DBMS_CLOUD_AI_AGENT 도구를 사용합니다.

+
+
+ +

Agent Factory 등록 주소: https://hmm-mcp.cloud-handson.com/mcp

+
+ +
+
+
+ 선택형 레거시 연동

ORDS Base URL

-

저장 후 접근 검증과 조회 연동이 이 주소를 사용합니다.

+

기존 VPD/ORDS 접근 검증과 ORDS Handler 운영에만 사용합니다. 비워 두면 해당 기능은 미설정 상태로 표시되며 HMM HR 질의에는 영향이 없습니다.

- +
diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java new file mode 100644 index 0000000..7b2b251 --- /dev/null +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/HmmRuntimeSettingsInitializerTest.java @@ -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); + } +} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index b9cf90c..bb7bc1d 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -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", "연차 이월"); } } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java index ddecfe5..43b9913 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/GuidedFlowTemplateTest.java @@ -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 diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java index 068e8bb..6eac9da 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/web/SettingsTemplateRenderTest.java @@ -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 준비 상태")
Default SSE/mcp/sseHMM MCPhttps://hmm-mcp.cloud-handson.com/mcp
Default Message/mcp/messages?sessionId={sessionId}TransportStreamable HTTP
Context SSE/mcp/{contextPath}/sse
Context Message/mcp/{contextPath}/messages?sessionId={sessionId}
Context Path영문/숫자로 시작하고 영문/숫자/_/-만 사용합니다. 예: vpd, ords-prod백오피스 호환 Endpoint/mcp — 로그인 세션에서 같은 HMM 도구 계약을 확인합니다.
AuthAuthorization: Bearer <사용자 Bearer Token> — 이 토큰 하나로 행 접근 컨텍스트를 설정합니다.Authorization: Bearer <HMM MCP Token> — 공개 MCP 서버의 고정 인증 토큰입니다.
Methods
Name ObjectORDS PathAgent Tool Instruction / parameter mapping
ords.query.admin.board_posts ADMIN.BOARD_POSTScb-ords/cb-object-query/admin/board_postsHMM_HR_TERM_RESOLVER
ORDS 행 접근 조회 도구 설명
- HTTP Authorization → 행 접근 컨텍스트 · prompt → GPT-5.4-mini Select AI 자연어 질의 · limit → 최대 반환 행 수 + resolve_hr_term.term → 표준 용어·코드 · search_hr_data.query → HR 데이터 · search_hr_policy.query → 규정 PDF 검색