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