test: prevent duplicate MCP tool names
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
jade
2026-07-27 12:37:35 +09:00
parent ff5ca4fc7a
commit 82c27bd468
2 changed files with 221 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
package io.shinhanlife.dap.lib.validation;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @package io.shinhanlife.dap.lib.validation
* @className McpToolNameValidator
* @description Validates unique MCP function names across tool modules
* @author 0986406
* @create 2026.07.27
* <pre>
* ---------- revision history ----------
* date author description
* ---------- --------- ---------------------------
* 2026.07.27 0986406 initial creation
* </pre>
*/
public final class McpToolNameValidator {
private static final Pattern TOOL_NAME_PATTERN = Pattern.compile("\\bname\\s*=\\s*\\\"([^\\\"]+)\\\"");
private McpToolNameValidator() {
}
public static void assertUnique(Path projectRoot) {
Map<String, List<ToolDeclaration>> declarationsByName = new LinkedHashMap<>();
try (var modules = Files.list(projectRoot)) {
modules.filter(Files::isDirectory)
.filter(path -> path.getFileName().toString().startsWith("dap-tool-"))
.filter(path -> !path.getFileName().toString().equals("dap-tool-core"))
.sorted()
.forEach(module -> collectDeclarations(module, declarationsByName));
} catch (IOException exception) {
throw new UncheckedIOException("Failed to scan MCP tool modules", exception);
}
List<Map.Entry<String, List<ToolDeclaration>>> duplicates = declarationsByName.entrySet().stream()
.filter(entry -> entry.getValue().size() > 1)
.sorted(Map.Entry.comparingByKey())
.toList();
if (!duplicates.isEmpty()) {
throw new IllegalStateException(buildDuplicateMessage(duplicates));
}
}
private static void collectDeclarations(Path module, Map<String, List<ToolDeclaration>> declarationsByName) {
Path sourceDirectory = module.resolve("src/main/java");
if (!Files.isDirectory(sourceDirectory)) {
return;
}
try (var sources = Files.walk(sourceDirectory)) {
sources.filter(path -> path.toString().endsWith(".java"))
.sorted()
.forEach(source -> collectDeclarations(module.getFileName().toString(), source, declarationsByName));
} catch (IOException exception) {
throw new UncheckedIOException("Failed to scan module " + module.getFileName(), exception);
}
}
private static void collectDeclarations(String moduleName, Path source,
Map<String, List<ToolDeclaration>> declarationsByName) {
String content;
try {
content = Files.readString(source);
} catch (IOException exception) {
throw new UncheckedIOException("Failed to read " + source, exception);
}
int annotationOffset = content.indexOf("@McpFunction");
while (annotationOffset >= 0) {
int openingParenthesis = content.indexOf('(', annotationOffset);
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
if (openingParenthesis < 0 || closingParenthesis < 0) {
break;
}
Matcher matcher = TOOL_NAME_PATTERN.matcher(content.substring(openingParenthesis + 1, closingParenthesis));
if (matcher.find()) {
String toolName = matcher.group(1);
int line = 1 + (int) content.substring(0, annotationOffset).chars().filter(character -> character == '\n').count();
declarationsByName.computeIfAbsent(toolName, ignored -> new ArrayList<>())
.add(new ToolDeclaration(moduleName, source, line));
}
annotationOffset = content.indexOf("@McpFunction", closingParenthesis + 1);
}
}
private static int findAnnotationEnd(String content, int openingParenthesis) {
if (openingParenthesis < 0) {
return -1;
}
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int index = openingParenthesis; index < content.length(); index++) {
char character = content.charAt(index);
if (inString) {
if (escaped) {
escaped = false;
} else if (character == '\\') {
escaped = true;
} else if (character == '\"') {
inString = false;
}
continue;
}
if (character == '\"') {
inString = true;
} else if (character == '(') {
depth++;
} else if (character == ')' && --depth == 0) {
return index;
}
}
return -1;
}
private static String buildDuplicateMessage(List<Map.Entry<String, List<ToolDeclaration>>> duplicates) {
StringBuilder message = new StringBuilder("Duplicate MCP tool name(s):");
for (Map.Entry<String, List<ToolDeclaration>> duplicate : duplicates) {
message.append("\n\n").append(duplicate.getKey());
duplicate.getValue().stream()
.sorted(Comparator.comparing(ToolDeclaration::moduleName).thenComparing(declaration -> declaration.source().toString()))
.forEach(declaration -> message.append("\n- ")
.append(declaration.moduleName())
.append(": ")
.append(declaration.source())
.append(':').append(declaration.line()));
}
return message.toString();
}
private record ToolDeclaration(String moduleName, Path source, int line) {
}
}

View File

@@ -0,0 +1,72 @@
package io.shinhanlife.dap.lib.validation;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* @package io.shinhanlife.dap.lib.validation
* @className McpToolNameValidatorTest
* @description MCP tool name uniqueness validation test
* @author 0986406
* @create 2026.07.27
* <pre>
* ---------- revision history ----------
* date author description
* ---------- --------- ---------------------------
* 2026.07.27 0986406 initial creation
* </pre>
*/
class McpToolNameValidatorTest {
@TempDir
Path temporaryRoot;
@Test
void rejectsDuplicateMcpFunctionNamesAcrossToolModules() throws IOException {
writeToolSource("dap-tool-first", "FirstTool.java", "first", "send_sms");
writeToolSource("dap-tool-second", "SecondTool.java", "second", "send_sms");
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> McpToolNameValidator.assertUnique(temporaryRoot));
assertTrue(exception.getMessage().contains("send_sms"));
assertTrue(exception.getMessage().contains("dap-tool-first"));
assertTrue(exception.getMessage().contains("dap-tool-second"));
}
@Test
void acceptsCurrentProjectToolNames() {
assertDoesNotThrow(() -> McpToolNameValidator.assertUnique(findProjectRoot()));
}
private void writeToolSource(String moduleName, String fileName, String className, String toolName) throws IOException {
Path source = temporaryRoot.resolve(moduleName).resolve("src/main/java/example").resolve(fileName);
Files.createDirectories(source.getParent());
Files.writeString(source, """
package example;
class %s {
@McpFunction(displayName = "%s", name = "%s", description = "test")
void execute() { }
}
""".formatted(className, className, toolName));
}
private Path findProjectRoot() {
Path current = Path.of("").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
if (current == null) {
throw new IllegalStateException("Could not locate Gradle project root");
}
return current;
}
}