diff --git a/.env.example b/.env.example index a97b948..11a65a2 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/deploy/vpd-backoffice/backoffice.env.example b/deploy/vpd-backoffice/backoffice.env.example index 76848fb..3826e01 100644 --- a/deploy/vpd-backoffice/backoffice.env.example +++ b/deploy/vpd-backoffice/backoffice.env.example @@ -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"}]' diff --git a/docs/design/743-mcp-advertised-agent-tool-validation/README.md b/docs/design/743-mcp-advertised-agent-tool-validation/README.md new file mode 100644 index 0000000..c0d1010 --- /dev/null +++ b/docs/design/743-mcp-advertised-agent-tool-validation/README.md @@ -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 정책과 운영 데이터는 이 변경에서 수정하지 않는다. diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md index 86e423e..dd01f19 100644 --- a/docs/runbooks/hmm-mcp-token-configuration.md +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -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`의 현재 조합은 다음과 같다. diff --git a/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpAgentToolStartupValidator.java b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpAgentToolStartupValidator.java new file mode 100644 index 0000000..a5f15ee --- /dev/null +++ b/vpd-backoffice/src/main/java/com/cloudhandson/vpdbackoffice/service/McpAgentToolStartupValidator.java @@ -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 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 registered = loadRegisteredTools(); + List missing = new ArrayList<>(); + List 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 loadRegisteredTools() { + try { + Map registered = new TreeMap<>(); + for (Map 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() ? "" : 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 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); + } +} diff --git a/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpAgentToolStartupValidatorTest.java b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpAgentToolStartupValidatorTest.java new file mode 100644 index 0000000..e54ad5a --- /dev/null +++ b/vpd-backoffice/src/test/java/com/cloudhandson/vpdbackoffice/service/McpAgentToolStartupValidatorTest.java @@ -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", + "", + "" + ); + } +}