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

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