refs #702: align HMM MCP contract and settings

This commit is contained in:
devmrko
2026-07-22 15:54:48 +09:00
parent adfd50d1fd
commit 536938895a
20 changed files with 383 additions and 197 deletions

View File

@@ -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 생성은 막히고 소스 보기만 사용합니다.

View File

@@ -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` 브랜치만 사용한다.

View File

@@ -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,

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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)
? "확인할 휴가·근태 용어, 동의어 또는 코드입니다."
: "조직, 직원, 휴가, 근태 또는 규정에 대한 완전한 자연어 질문입니다.";
}
}
}

View File

@@ -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://")) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &lt;HMM MCP Token&gt;</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 &lt;사용자 Bearer Token&gt;</code>이 토큰 하나로 행 접근 컨텍스트를 설정합니다.</td>
<td><code>Authorization: Bearer &lt;HMM MCP Token&gt;</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>

View File

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

View File

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

View File

@@ -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", "연차 이월");
}
}
}

View File

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

View File

@@ -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 준비 상태")