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

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