diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/validation/McpToolNameValidator.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/validation/McpToolNameValidator.java new file mode 100644 index 00000000..b77bc3e8 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/validation/McpToolNameValidator.java @@ -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 + *
+ * ---------- revision history ----------
+ * date       author    description
+ * ---------- --------- ---------------------------
+ * 2026.07.27 0986406    initial creation
+ * 
+ */ +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> 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>> 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> 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> 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>> duplicates) { + StringBuilder message = new StringBuilder("Duplicate MCP tool name(s):"); + for (Map.Entry> 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) { + } +} diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/validation/McpToolNameValidatorTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/validation/McpToolNameValidatorTest.java new file mode 100644 index 00000000..32cc6052 --- /dev/null +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/validation/McpToolNameValidatorTest.java @@ -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 + *
+ * ---------- revision history ----------
+ * date       author    description
+ * ---------- --------- ---------------------------
+ * 2026.07.27 0986406    initial creation
+ * 
+ */ +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; + } +}