test: prevent duplicate MCP tool names
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user