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

@@ -535,7 +535,7 @@ src/main/resources/
```java
@McpFunction(
name = "sample.claim.search.resource",
name = "oth.cmm.claim.search",
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json"
)
@@ -570,3 +570,21 @@ Tool 실행이 끝나면 아래 로그는 Schema 정의가 아니라 **검증을
```powershell
.\gradlew.bat :dap-tool-oth:test --tests "io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequestSchemaTest"
```
### Tool Naming Convention
All Tool names use the four-level lowercase format `pod.domain.service.action`. Do not use underscores or CamelCase; use a hyphen (`-`) only when a single level has multiple words.
- `pod`: deployment Tool Pod/module (`dap-tool-oth``oth`, `dap-tool-sms``sms`)
- `domain`: business-domain package (`cmm`, `smp`, `sol`, etc.)
- `service`: business service or resource
- `action`: the requested operation (`search`, `list`, `detail`, `issue`, `inquiry`, etc.)
```text
oth.cmm.bond.issue
oth.cmm.claim.search
oth.sol.request.list
oth.smp.weather.inquiry
```
When Scaffold receives `dap-tool-oth`, `cmm`, and `ClaimSearch`, it generates `oth.cmm.claim.search`. The `validateMcpToolNames` Gradle task rejects both a duplicate name and any name outside this format before packaging, including its source file and line number.

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 {

View File

@@ -6,7 +6,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
@McpTool(routingType = "MCI", categoryKey = "cmm")
public interface BalanceUseCase {
@McpFunction(register = false, displayName = "balance 툴", name = "balance",
@McpFunction(register = false, displayName = "balance 툴", name = "oth.cmm.balance.inquiry",
description = "고객의 계좌 잔액을 조회합니다.",
prompt = "고객 계좌 잔액을 조회해줘.",
mappingId = "ACC_001"

View File

@@ -6,11 +6,11 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
@McpTool(
routingType = "MCI",
categoryKey = "claim"
categoryKey = "cmm"
)
public interface BillingProcessUseCase {
Object getStatus(BillingStatusRequest req);
@McpFunction(register = false, displayName = "process 툴", name = "billing_process", description = "청구 처리", prompt = "현재 접수된 청구건에 대한 심사 처리를 진행해.", mappingId = "BILL_002")
@McpFunction(register = false, displayName = "process 툴", name = "oth.cmm.billing.process", description = "청구 처리", prompt = "현재 접수된 청구건에 대한 심사 처리를 진행해.", mappingId = "BILL_002")
Object processBilling(BillingProcessRequest data);
}

View File

@@ -6,11 +6,11 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
@McpTool(
routingType = "EAI",
categoryKey = "policy"
categoryKey = "cmm"
)
public interface BondIssueUseCase {
Object check(BondCheckRequest req);
@McpFunction(displayName = "issue 툴", name = "bond_issue", register = false, description = "증권 발행 테스트1", prompt = "디지털 증권 발행 프로세스를 실행해.", mappingId = "BOND_002")
@McpFunction(displayName = "issue 툴", name = "oth.cmm.bond.issue", register = false, description = "증권 발행 테스트1", prompt = "디지털 증권 발행 프로세스를 실행해.", mappingId = "BOND_002")
Object issue(BondIssueRequest data);
}

View File

@@ -14,7 +14,7 @@ public interface ClaimSearchSchemaSampleUseCase {
@McpFunction(
register = false,
displayName = "Claim search JSON Schema sample",
name = "sample.claim.search.resource",
name = "oth.cmm.claim.search",
description = "Claim search Tool sample using input and output JSON Schema resources.",
prompt = "Search an insurance claim by claim number or contract number.",
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",

View File

@@ -6,14 +6,14 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
@McpTool(
routingType = "HTTP",
categoryKey = "hr"
categoryKey = "cmm"
)
public interface CommonUtilityUseCase {
Object registerVacation(VacationRegisterRequest req);
@McpFunction(register = false, displayName = "get_leave_count 툴", name = "get_leave_count", description = "연차 갯수 조회", prompt = "현재 사용 가능한 남은 연차 일수를 알려줘.", mappingId = "HR_VAC_02")
@McpFunction(register = false, displayName = "get_leave_count 툴", name = "oth.cmm.leave.count", description = "연차 갯수 조회", prompt = "현재 사용 가능한 남은 연차 일수를 알려줘.", mappingId = "HR_VAC_02")
Object getLeaveCount(LeaveCountRequest data);
@McpFunction(register = false, displayName = "secret_tool 툴", name = "secret_tool", description = "비공개 툴 테스트", prompt = "숨겨진 툴 강제 호출", mappingId = "SECRET_001", visible = false)
@McpFunction(register = false, displayName = "secret_tool 툴", name = "oth.cmm.secret.execute", description = "비공개 툴 테스트", prompt = "숨겨진 툴 강제 호출", mappingId = "SECRET_001", visible = false)
Object secretTool(LeaveCountRequest data);
}

View File

@@ -6,11 +6,11 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
@McpTool(
routingType = "HTTP",
categoryKey = "contract"
categoryKey = "cmm"
)
public interface ContractInquiryUseCase {
Object getStatus(ContractStatusRequest req);
@McpFunction(register = false, displayName = "contract_detail 툴", name = "contract_detail", description = "계약상세 조회", prompt = "김신한 고객의 계약 상세 내역을 알려줘.", mappingId = "CNTR_002")
@McpFunction(register = false, displayName = "contract_detail 툴", name = "oth.cmm.contract.detail", description = "계약상세 조회", prompt = "김신한 고객의 계약 상세 내역을 알려줘.", mappingId = "CNTR_002")
Object getDetail(ContractDetailRequest data);
}

View File

@@ -6,11 +6,11 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.*;
@McpTool(
routingType = "TCP",
categoryKey = "customer"
categoryKey = "cmm"
)
public interface CustomerInfoUseCase {
Object getGrade(CustomerGradeRequest req);
@McpFunction(register = false, displayName = "detail 툴", name = "customer_detail", description = "고객상세 정보 조회", prompt = "이 고객의 상세 기본정보(주소, 연락처 등)를 알려줘.", mappingId = "CRM_002")
@McpFunction(register = false, displayName = "detail 툴", name = "oth.cmm.customer.detail", description = "고객상세 정보 조회", prompt = "이 고객의 상세 기본정보(주소, 연락처 등)를 알려줘.", mappingId = "CRM_002")
Object getDetail(CustomerDetailRequest data);
}

View File

@@ -25,7 +25,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
public interface MetaCommonCodeUseCase {
@McpFunction(
displayName = "메타 통합코드 조회 툴",
name = "metaCommonCode",
name = "oth.cmm.common-code.lookup",
description = "메타 통합코드 목록을 조회해줘",
prompt = "메타 통합코드 목록을 조회해줘",
mappingId = "CLCNNB00001",

View File

@@ -25,7 +25,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
public interface MetaTableUseCase {
@McpFunction(
displayName = "메타 테이블 조회 툴",
name = "metaTable",
name = "oth.cmm.meta.table",
description = "메타 테이블 정보 목록을 조회해줘",
prompt = "메타 테이블 정보 목록을 조회해줘",
mappingId = "CLCNNB00001",

View File

@@ -13,7 +13,7 @@ import java.util.Map;
public interface TemplateUtilityUseCase {
@McpFunction(
displayName = "템플릿 유틸리티",
name = "get_template_file_url",
name = "oth.cmm.template.url",
description = "요청한 템플릿(엑셀, 워드 등) 파일(양식)을 다운로드 받을 수 있는 시스템 URL을 반환합니다. AI는 이 URL을 사용자에게 마크다운 링크 형태로 제공해야 합니다.",
prompt = "요청하신 템플릿 양식 파일 다운로드 URL은 다음과 같습니다. 클릭하여 다운로드하세요:"
)

View File

@@ -10,7 +10,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.DailyQuoteResponse;
categoryKey = "smp"
)
public interface DailyQuoteToolUseCase {
@McpFunction(register = false, displayName = "랜덤 명언 툴", name = "daily_quote",
@McpFunction(register = false, displayName = "랜덤 명언 툴", name = "oth.smp.quote.daily",
description = "무작위로 영감을 주는 명언을 하나 가져옵니다.",
prompt = "오늘의 명언 하나 알려줘, 동기부여 명언 등",
mappingId = "QUOTE_001"

View File

@@ -12,7 +12,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
categoryKey = "smp"
)
public interface ExchangeRateToolUseCase {
@McpFunction(register = false, displayName = "실시간 환율 조회 툴", name = "exchange_rate",
@McpFunction(register = false, displayName = "실시간 환율 조회 툴", name = "oth.smp.exchange-rate.inquiry",
description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)",
prompt = "현재 달러 환율 알려줘, 엔화 환율은?",
mappingId = "EXCHANGE_001"

View File

@@ -11,7 +11,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.TeamMemberRequest;
public interface TeamMemberUseCase {
@McpFunction(
displayName = "신한라이프 MCP, TOOL 파트 구성원 조회",
name = "get_smp_members",
name = "oth.smp.team.list",
description = "신한라이프 MCP, TOOL 파트 구성원을 조회합니다.",
prompt = "신한라이프 MCP, TOOL 파트 구성원을 조회해 줘. (주의: 응답 시 ** 등 마크다운 기호를 절대 사용하지 말고 평문으로만 출력해 줘)",
mappingId = "DIRECT0001",

View File

@@ -9,7 +9,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.*;
categoryKey = "smp"
)
public interface WeatherToolUseCase {
@McpFunction(register = false, displayName = "날씨 조회 툴", name = "weather",
@McpFunction(register = false, displayName = "날씨 조회 툴", name = "oth.smp.weather.inquiry",
description = "특정 도시의 현재 날씨, 온도, 풍속 정보를 조회합니다.",
prompt = "서울 날씨 알려줘, 부산 기온 알려줘 등 실시간 기상 조회",
mappingId = "WEATHER_001"

View File

@@ -26,7 +26,7 @@ public interface SolReqDetailUseCase {
@McpFunction(
displayName = "SolReqDetail 툴",
name = "solReqDetail",
name = "oth.sol.request.detail",
description = "SOL 의뢰서 상세 조회",
prompt = "SOL 의뢰서 상세 조회해줘",
mappingId = "SOLG00000002",

View File

@@ -11,7 +11,7 @@ import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqListRequest;
public interface SolReqListUseCase {
@McpFunction(
displayName = "SolReqList 툴",
name = "solReqList",
name = "oth.sol.request.list",
description = "SOL 의뢰서 목록 조회해줘",
prompt = "SOL 의뢰서 목록 조회해줘",
mappingId = "SOLG00000001",