feat: standardize tool names by pod domain action
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m55s

This commit is contained in:
jade
2026-08-04 16:39:03 +09:00
parent a58af1606e
commit e858859021
22 changed files with 108 additions and 38 deletions

View File

@@ -6,6 +6,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Scanner;
/**
@@ -199,8 +200,7 @@ public class ToolScaffolder {
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent);
String rawToolName = baseName.isEmpty() ? baseName : Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1);
String toolName = group.toLowerCase() + "." + rawToolName;
String toolName = toToolName(moduleName, group, baseName);
String serviceInterfaceContent = """
package %s.usecase;
@@ -672,6 +672,24 @@ public class ToolScaffolder {
return log.toString();
}
private static String toToolName(String moduleName, String group, String baseName) {
String pod = moduleName.startsWith("dap-tool-")
? moduleName.substring("dap-tool-".length())
: "oth";
String normalizedName = baseName.replaceAll("([a-z0-9])([A-Z])", "$1 $2")
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", " ")
.trim();
String[] words = normalizedName.split("\\s+");
String service = words[0];
String action = words.length == 1 ? "execute" : words[words.length - 1];
return "%s.%s.%s.%s".formatted(
pod.toLowerCase(Locale.ROOT),
group.toLowerCase(Locale.ROOT),
service,
action);
}
private static String toPascalCase(String str) {
if (str == null || str.isEmpty()) {
return str;

View File

@@ -28,6 +28,7 @@ import java.util.regex.Pattern;
public final class McpToolNameValidator {
private static final Pattern TOOL_NAME_PATTERN = Pattern.compile("\\bname\\s*=\\s*\\\"([^\\\"]+)\\\"");
private static final Pattern TOOL_NAME_CONVENTION = Pattern.compile("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$");
private McpToolNameValidator() {
}
@@ -45,6 +46,14 @@ public final class McpToolNameValidator {
throw new UncheckedIOException("Failed to scan MCP tool modules", exception);
}
List<Map.Entry<String, List<ToolDeclaration>>> invalidNames = declarationsByName.entrySet().stream()
.filter(entry -> !TOOL_NAME_CONVENTION.matcher(entry.getKey()).matches())
.sorted(Map.Entry.comparingByKey())
.toList();
if (!invalidNames.isEmpty()) {
throw new IllegalStateException(buildInvalidNameMessage(invalidNames));
}
List<Map.Entry<String, List<ToolDeclaration>>> duplicates = declarationsByName.entrySet().stream()
.filter(entry -> entry.getValue().size() > 1)
.sorted(Map.Entry.comparingByKey())
@@ -129,6 +138,21 @@ public final class McpToolNameValidator {
return -1;
}
private static String buildInvalidNameMessage(List<Map.Entry<String, List<ToolDeclaration>>> invalidNames) {
StringBuilder message = new StringBuilder("Invalid MCP tool name(s): expected pod.domain.service.action using lowercase letters, digits, or hyphens.");
for (Map.Entry<String, List<ToolDeclaration>> invalidName : invalidNames) {
message.append("\n\n").append(invalidName.getKey());
invalidName.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 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) {

View File

@@ -19,7 +19,7 @@ class ToolScaffolderTest {
String useCase = Files.readString(root.resolve("usecase/ClaimSearchUseCase.java"));
String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java"));
assertTrue(useCase.contains("name = \"cmm.claimSearch\""));
assertTrue(useCase.contains("name = \"oth.cmm.claim.search\""));
assertTrue(useCase.contains("version = \"1.0.0\""));
assertTrue(useCase.contains("timeoutMillis = 300000L"));
assertTrue(response.contains("@McpOutputSchema"));

View File

@@ -74,7 +74,7 @@ class ToolSchemaResolverTest {
static class InlineSchemaTool {
@McpFunction(
displayName = "inline",
name = "sample.inline",
name = "test.schema.inline",
description = "inline schema",
inputSchema = "{\"type\":\"object\",\"properties\":{\"keyword\":{\"type\":\"string\"}},\"additionalProperties\":false}")
void search(AutomaticRequest request) {
@@ -82,13 +82,13 @@ class ToolSchemaResolverTest {
}
static class AutomaticSchemaTool {
@McpFunction(displayName = "automatic", name = "sample.automatic", description = "automatic schema")
@McpFunction(displayName = "automatic", name = "test.schema.automatic", description = "automatic schema")
void search(AutomaticRequest request) {
}
}
static class AutomaticOutputSchemaTool {
@McpFunction(displayName = "automatic-output", name = "sample.automatic-output", description = "automatic output")
@McpFunction(displayName = "automatic-output", name = "test.schema.automatic-output", description = "automatic output")
SimpleResponse search(AutomaticRequest request) {
return null;
}
@@ -106,7 +106,7 @@ class ToolSchemaResolverTest {
static class OutputSchemaTool {
@McpFunction(
displayName = "output",
name = "sample.output",
name = "test.schema.output",
description = "output schema",
outputSchema = "{\"type\":\"object\",\"properties\":{\"resultCode\":{\"type\":\"string\"}},\"required\":[\"resultCode\"],\"additionalProperties\":false}")
void search(AutomaticRequest request) {

View File

@@ -31,26 +31,36 @@ class McpToolNameValidatorTest {
@Test
void rejectsDuplicateMcpFunctionNamesAcrossToolModules() throws IOException {
writeToolSource("dap-tool-first", "FirstTool.java", "first", "send_sms");
writeToolSource("dap-tool-second", "SecondTool.java", "second", "send_sms");
writeToolSource("dap-tool-first", "FirstTool.java", "first", "oth.sms.notification.send");
writeToolSource("dap-tool-second", "SecondTool.java", "second", "oth.sms.notification.send");
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> McpToolNameValidator.assertUnique(temporaryRoot));
assertTrue(exception.getMessage().contains("send_sms"));
assertTrue(exception.getMessage().contains("oth.sms.notification.send"));
assertTrue(exception.getMessage().contains("dap-tool-first"));
assertTrue(exception.getMessage().contains("dap-tool-second"));
}
@Test
void validationRunnerRejectsDuplicateMcpFunctionNamesBeforePackaging() throws IOException {
writeToolSource("dap-tool-first", "FirstTool.java", "first", "send_sms");
writeToolSource("dap-tool-second", "SecondTool.java", "second", "send_sms");
writeToolSource("dap-tool-first", "FirstTool.java", "first", "oth.sms.notification.send");
writeToolSource("dap-tool-second", "SecondTool.java", "second", "oth.sms.notification.send");
assertThrows(IllegalStateException.class,
() -> McpToolNameValidationRunner.validate(temporaryRoot));
}
@Test
void rejectsToolNameOutsidePodDomainServiceActionConvention() throws IOException {
writeToolSource("dap-tool-first", "FirstTool.java", "first", "bond_issue");
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> McpToolNameValidator.assertUnique(temporaryRoot));
assertTrue(exception.getMessage().contains("Invalid MCP tool name(s)"));
assertTrue(exception.getMessage().contains("bond_issue"));
}
@Test
void acceptsCurrentProjectToolNames() {
assertDoesNotThrow(() -> McpToolNameValidator.assertUnique(findProjectRoot()));
@@ -61,11 +71,11 @@ class McpToolNameValidatorTest {
Path root = findProjectRoot();
assertToolName(root, "dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/CustomerInfoUseCase.java",
"customer_detail", "detail");
"oth.cmm.customer.detail", "detail");
assertToolName(root, "dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/BillingProcessUseCase.java",
"billing_process", "process");
"oth.cmm.billing.process", "process");
assertToolName(root, "dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/BondIssueUseCase.java",
"bond_issue", "issue");
"oth.cmm.bond.issue", "issue");
}
private void assertToolName(Path root, String relativePath, String expectedName, String legacyName) throws IOException {