refs #743: validate advertised ADB MCP tools at startup

This commit is contained in:
devmrko
2026-08-03 11:07:15 +09:00
parent 22c0b571e2
commit 750bfbab5b
6 changed files with 303 additions and 2 deletions

View File

@@ -73,7 +73,8 @@ export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
# 단일 Select AI 도구 호환 설정. 여러 Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
# AGENT_TOOL targetName은 서버 시작 시 USER_AI_AGENT_TOOLS의 ENABLED 상태를 검증합니다.
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"

View File

@@ -11,7 +11,9 @@ BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
# AGENT_TOOL targetName must exist with STATUS=ENABLED in USER_AI_AGENT_TOOLS.
# The application fails startup before advertising an invalid configured contract.
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"SELECT_AI"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'

View File

@@ -0,0 +1,64 @@
# #743 MCP 광고 Agent Tool 시작 검증
## 문제
백오피스 MCP의 `tools/list``BACKOFFICE_MCP_TOOLS`에 선언된 공개 계약을 광고한다.
`AGENT_TOOL``targetName``DBMS_CLOUD_AI_AGENT.RUN_TOOL`에 전달하지만, 현재는 서버
시작 시 해당 Tool이 ADB에 실제로 등록되어 있는지 확인하지 않는다. 따라서 Discovery는
성공하고 첫 `tools/call`에서만 실패할 수 있다.
## 목표
- 공개 Tool 이름과 입력 스키마는 배포 설정에서 안정적으로 관리한다.
- `AGENT_TOOL`의 내부 `targetName`은 현재 JDBC 실행 사용자의
`USER_AI_AGENT_TOOLS`에서 존재하고 `ENABLED` 상태여야 한다.
- 누락·비활성·메타데이터 조회 실패는 서버 시작을 중단한다.
- Java 자체 구현인 `SELECT_AI`는 ADB Agent Tool 검증 대상에서 제외한다.
## 처리 흐름
```text
Spring 설정 로드
→ EnvironmentMcpToolCatalog가 BACKOFFICE_MCP_TOOLS 검증
→ SmartInitializingSingleton이 AGENT_TOOL targetName 수집
→ USER_AI_AGENT_TOOLS 조회
→ 모두 ENABLED
├─ 예: MCP endpoint 기동 완료, tools/list 광고
└─ 아니오: 기동 실패, 외부에 불완전한 Tool 계약을 광고하지 않음
```
Discovery 요청 때마다 DB를 조회하지 않는다. ADB Agent Tool은 운영 설정이므로 시작 시 한 번
검증하고, 설정이나 DB Tool을 바꾼 뒤에는 애플리케이션을 재기동해 계약을 다시 확정한다.
## 실패 정책
누락된 Tool을 `tools/list`에서 자동 제외하지 않는다. 호출 가능한 Tool 집합이 환경에 따라
조용히 축소되면 Agent instruction과 실제 도구 목록이 어긋나기 때문이다. 설정에 선언된
`AGENT_TOOL`이 하나라도 누락되거나 `ENABLED`가 아니면 fail-closed로 기동을 실패시킨다.
오류에는 설정의 내부 Tool 이름과 상태만 포함한다. Bearer Token, Tool 입력, DB 접속정보는
로그에 기록하지 않는다.
## 구현 경계
- `EnvironmentMcpToolCatalog`: 공개 이름, 인자, 실행 유형, 내부 target 형식 검증
- `McpAgentToolStartupValidator`: DB 등록·상태 검증
- `McpSseService`: 검증 완료된 catalog를 `tools/list`로 광고하고 `tools/call`로 실행
- `JdbcHmmAiAgentToolRunner`: 검증된 `targetName`을 동일 JDBC 세션에서 실행
`USER_AI_AGENT_TOOLS``RUN_TOOL`을 실행하는 기본 datasource 사용자 기준 View다. 다른
스키마의 Tool을 임의로 검색하거나 `ALL_*` 권한을 요구하지 않는다.
## 검증 기준
1. `AGENT_TOOL`이 모두 `ENABLED`면 검증이 통과한다.
2. Tool이 누락되면 누락된 이름을 포함해 실패한다.
3. Tool이 `DISABLED`면 이름과 상태를 포함해 실패한다.
4. 같은 ADB Tool을 여러 공개 Tool이 참조해도 한 번만 검증한다.
5. `SELECT_AI`만 구성되면 `USER_AI_AGENT_TOOLS`를 조회하지 않는다.
6. 기존 Maven 전체 테스트와 MCP discovery/call 테스트가 통과한다.
## 롤백
검증 컴포넌트와 테스트를 제거하면 기존 설정 기반 광고 방식으로 돌아간다. DB Tool,
프로필, VPD 정책과 운영 데이터는 이 변경에서 수정하지 않는다.

View File

@@ -33,6 +33,12 @@ HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한
| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 |
| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 |
`tools/list``BACKOFFICE_MCP_TOOLS`의 공개 이름·설명·입력 스키마를 광고한다.
`executionType=AGENT_TOOL`인 항목은 애플리케이션 시작 시 기본 datasource 사용자의
`USER_AI_AGENT_TOOLS`에서 `targetName`이 실제로 존재하고 `STATUS=ENABLED`인지 검증한다.
하나라도 누락되거나 비활성이면 서버는 기동을 실패하며 불완전한 Tool 목록을 광고하지 않는다.
`search_hr_data`처럼 `executionType=SELECT_AI`인 Java 자체 구현 Tool은 이 검증 대상이 아니다.
## 포털의 현재 공용 토큰 조합
`config/vpd_token_presets.json`의 현재 조합은 다음과 같다.

View File

@@ -0,0 +1,111 @@
package com.cloudhandson.vpdbackoffice.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/** Fails startup when an advertised ADB Agent Tool cannot actually be called. */
@Component
public class McpAgentToolStartupValidator implements SmartInitializingSingleton {
static final String TOOL_METADATA_SQL = """
SELECT TOOL_NAME, STATUS
FROM USER_AI_AGENT_TOOLS
""";
private static final Logger log =
LoggerFactory.getLogger(McpAgentToolStartupValidator.class);
private final McpToolCatalog toolCatalog;
private final JdbcTemplate jdbcTemplate;
public McpAgentToolStartupValidator(
McpToolCatalog toolCatalog,
JdbcTemplate jdbcTemplate
) {
this.toolCatalog = toolCatalog;
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void afterSingletonsInstantiated() {
validateAdvertisedAgentTools();
}
void validateAdvertisedAgentTools() {
Set<String> requiredTargets = new TreeSet<>();
toolCatalog.tools().stream()
.filter(McpToolDefinition::agentTool)
.map(McpToolDefinition::targetName)
.map(value -> value.toUpperCase(Locale.ROOT))
.forEach(requiredTargets::add);
if (requiredTargets.isEmpty()) {
log.info("MCP startup validation skipped: no ADB Agent Tool is advertised");
return;
}
Map<String, String> registered = loadRegisteredTools();
List<String> missing = new ArrayList<>();
List<String> notEnabled = new ArrayList<>();
for (String requiredTarget : requiredTargets) {
if (!registered.containsKey(requiredTarget)) {
missing.add(requiredTarget);
continue;
}
String status = registered.get(requiredTarget);
if (!"ENABLED".equals(status)) {
notEnabled.add(requiredTarget + "=" + status);
}
}
if (!missing.isEmpty() || !notEnabled.isEmpty()) {
throw new IllegalStateException(
"Advertised MCP Agent Tool validation failed: missing=" + missing
+ ", notEnabled=" + notEnabled);
}
log.info("Validated {} advertised ADB Agent Tool(s): {}",
requiredTargets.size(), requiredTargets);
}
private Map<String, String> loadRegisteredTools() {
try {
Map<String, String> registered = new TreeMap<>();
for (Map<String, Object> row : jdbcTemplate.queryForList(TOOL_METADATA_SQL)) {
String toolName = normalized(row, "TOOL_NAME");
if (!toolName.isBlank()) {
String status = normalized(row, "STATUS");
registered.put(toolName, status.isBlank() ? "<NULL>" : status);
}
}
return registered;
} catch (DataAccessException exception) {
throw new IllegalStateException(
"Unable to validate advertised MCP Agent Tools in USER_AI_AGENT_TOOLS",
exception);
}
}
private String normalized(Map<String, Object> row, String column) {
Object value = row.get(column);
if (value == null) {
value = row.entrySet().stream()
.filter(entry -> column.equalsIgnoreCase(entry.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
return value == null ? "" : value.toString().trim().toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,117 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
class McpAgentToolStartupValidatorTest {
private final McpToolCatalog toolCatalog = mock(McpToolCatalog.class);
private final JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
private final McpAgentToolStartupValidator validator =
new McpAgentToolStartupValidator(toolCatalog, jdbcTemplate);
@Test
void acceptsEnabledAgentToolsAndChecksDuplicateTargetOnce() {
when(toolCatalog.tools()).thenReturn(List.of(
agentTool("resolve_hr_term", "HMM_HR_TERM_RESOLVER"),
agentTool("resolve_hr_alias", "HMM_HR_TERM_RESOLVER"),
agentTool("search_hr_policy", "HMM_HR_POLICY_SEARCH")
));
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
.thenReturn(List.of(
Map.of("TOOL_NAME", "HMM_HR_TERM_RESOLVER", "STATUS", "ENABLED"),
Map.of("TOOL_NAME", "HMM_HR_POLICY_SEARCH", "STATUS", "enabled")
));
validator.validateAdvertisedAgentTools();
verify(jdbcTemplate).queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL);
}
@Test
void rejectsMissingAgentTool() {
when(toolCatalog.tools()).thenReturn(List.of(
agentTool("search_hr_policy", "HMM_HR_POLICY_SEARCH")
));
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
.thenReturn(List.of());
assertThatThrownBy(validator::validateAdvertisedAgentTools)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("missing=[HMM_HR_POLICY_SEARCH]");
}
@Test
void rejectsDisabledAgentTool() {
when(toolCatalog.tools()).thenReturn(List.of(
agentTool("resolve_hr_term", "HMM_HR_TERM_RESOLVER")
));
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
.thenReturn(List.of(
Map.of("tool_name", "hmm_hr_term_resolver", "status", "DISABLED")
));
assertThatThrownBy(validator::validateAdvertisedAgentTools)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("notEnabled=[HMM_HR_TERM_RESOLVER=DISABLED]");
}
@Test
void skipsDatabaseLookupForApplicationNativeTools() {
when(toolCatalog.tools()).thenReturn(List.of(selectAiTool("search_hr_data")));
validator.validateAdvertisedAgentTools();
verify(jdbcTemplate, never())
.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL);
}
@Test
void rejectsMetadataLookupFailure() {
when(toolCatalog.tools()).thenReturn(List.of(
agentTool("search_hr_policy", "HMM_HR_POLICY_SEARCH")
));
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
.thenThrow(new DataAccessResourceFailureException("metadata unavailable"));
assertThatThrownBy(validator::validateAdvertisedAgentTools)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("USER_AI_AGENT_TOOLS")
.hasCauseInstanceOf(DataAccessResourceFailureException.class);
}
private McpToolDefinition agentTool(String publicName, String targetName) {
return new McpToolDefinition(
publicName,
publicName,
"description",
"query",
"query description",
"AGENT_TOOL",
targetName,
"P_QUERY"
);
}
private McpToolDefinition selectAiTool(String publicName) {
return new McpToolDefinition(
publicName,
publicName,
"description",
"query",
"query description",
"SELECT_AI",
"",
""
);
}
}