diff --git a/README.md b/README.md index f8da4873..f168236b 100644 --- a/README.md +++ b/README.md @@ -269,3 +269,38 @@ Tool 관련 공통 기능은 `dap-was-*` 모듈명만 기준으로 동작합니 - Tool Scaffold는 Pod 이름을 Tool 함수명에 포함하지 않습니다. 함수명은 `도메인_비즈니스_행위` 형식입니다. 예: `cmm_notification_send` - Pod Scaffold와 Gateway Scaffold 화면/API의 모듈 목록도 `dap-was-*` 명칭으로 통일되어 있습니다. - Tool Source Update 기능은 `dap-was-*` 아래의 `*UseCase.java`를 검색합니다. + +## 14. BC-DAB-STD-003 Tool Schema V17 적용 + +각 Tool의 표준 명세는 Tool Pod별 다음 경로에서 관리합니다. + +```text +dap-was-{pod}/src/main/resources/tool-definitions/{categoryKey}/{toolName}.yml +``` + +Tool 이름은 Pod 정보를 포함하지 않는 `도메인_서비스_행위` 형태의 영문 소문자 snake_case를 사용하며, +정규식 `^[a-z][a-z0-9_]{2,63}$`을 만족해야 합니다. 예: `cmm_claim_search`. + +필수 항목은 `name`, `display_name`, `version`, `category_key`, 설명 4개 요소(function, when_to_use, +when_not_to_use, io_limits), `display_description`, 예시 질의 3~10건, 동작 힌트 3개(read_only, +destructive, idempotent), `parameters_schema`입니다. 입력 Schema는 루트 `type: object`, 각 property의 +`description`, `additionalProperties: false`를 갖춰야 합니다. 선택 운영 항목은 `tags`, +`legacy_interface_id`, `required_env_keys`, `owner_org`입니다. + +기동 시 `tool-definitions/**/*.yml`을 한 번 읽어 이름 기준으로 캐시하고, `@McpTool` 실행 정보와 결합한 +동일한 `ToolMetadata`를 `/tool-manifest`, Tool Pod MCP, Gateway MCP에 사용합니다. + +입력 Schema 우선순위는 `inputSchemaResource` → V17 `parameters_schema` → DTO 자동 생성이고, 출력은 +`outputSchemaResource` → 명시 Output Schema → `@McpOutputSchema` 기반 생성입니다. Output Schema를 +명시한 Tool만 최종 응답 검증을 수행합니다. + +Scaffold 화면의 `Tool Schema V17 Metadata` 영역에서는 기능 설명, 사용/비사용 조건, 입출력 제한, +표시 설명, 예시 질의, 태그와 소유 조직을 입력합니다. Java 소스와 함께 V17 YAML이 생성되며, 기본값은 +배포 전에 업무 담당자가 실제 의미에 맞게 검토해야 합니다. + +```powershell +.\gradlew.bat validateMcpToolNames validateToolSchemaV17 +``` + +`bootJar`는 두 검증에 의존하므로 이름 중복, 필수 항목 누락, Java Tool과 YAML 명세 불일치가 있으면 +Docker 이미지 생성 전에 빌드가 실패합니다. diff --git a/build.gradle b/build.gradle index 2cd07775..d4f2318f 100644 --- a/build.gradle +++ b/build.gradle @@ -89,9 +89,19 @@ tasks.register('validateMcpToolNames', JavaExec) { args rootProject.projectDir.absolutePath } +tasks.register('validateToolSchemaV17', JavaExec) { + group = 'verification' + description = 'Validates BC-DAB-STD-003 V17 definitions for every @McpTool.' + dependsOn toolCoreProject.tasks.named('classes') + classpath = toolCoreProject.sourceSets.main.runtimeClasspath + mainClass.set('io.shinhanlife.dap.lib.validation.ToolSchemaV17ValidationRunner') + args rootProject.projectDir.absolutePath +} + subprojects { // 배포용 Spring Boot JAR 생성 전에 Tool 이름 중복 검증을 강제합니다. tasks.matching { it.name == 'bootJar' }.configureEach { dependsOn rootProject.tasks.named('validateMcpToolNames') + dependsOn rootProject.tasks.named('validateToolSchemaV17') } } diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java index 18af12ee..f47aff4b 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java @@ -98,8 +98,19 @@ public class ScaffoldingController { if (inputFields.isEmpty()) { inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false)); } - - return ToolScaffolder.scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, date, register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields, httpApiName); + ToolScaffolder.ToolDefinitionOptions definitionOptions = new ToolScaffolder.ToolDefinitionOptions( + req.get("functionDescription"), + req.get("whenToUse"), + req.get("whenNotToUse"), + req.get("ioLimits"), + req.get("displayDescription"), + parseDelimited(req.get("exampleQueries")), + parseDelimited(req.get("tags")), + req.get("ownerOrg")); + + return ToolScaffolder.scaffold(baseName, interfaceId, title, description, group, routingType, + moduleName, author, date, register, clientSystemCode, inputSchemaResource, + outputSchemaResource, inputFields, outputFields, httpApiName, definitionOptions); } catch (Exception e) { return "오류 발생: " + e.getMessage(); } @@ -151,9 +162,11 @@ public class ScaffoldingController { Generate an MCP Tool scaffold from the user request. Return JSON only. Do not add Markdown, explanations, or code fences. The response must have this exact shape: - {"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]} + {"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]} categoryKey must be exactly three lowercase letters or digits. routingType must be either HTTP or MCI. httpApiName can contain only letters, digits, hyphens, and underscores. + Write every V17 metadata field for its distinct purpose; do not copy the same sentence into all fields. + Generate 3 to 10 realistic exampleQueries and concise search tags. Use MCP_TOOL for ownerOrg unless the user names an owner. Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal. Keep all field names valid Java camelCase identifiers. Generate at most 10 fields per list. Do not generate interfaceId or clientSystemCode; those must come from a real integration contract. @@ -163,15 +176,7 @@ public class ScaffoldingController { String response = generateAiContent(prompt, req.get("model")); ToolDraft draft = objectMapper.readValue(stripCodeFence(response), ToolDraft.class); ToolDraft validatedDraft = validateToolDraft(draft); - return ResponseEntity.ok(Map.of( - "baseName", validatedDraft.baseName(), - "title", validatedDraft.title(), - "description", validatedDraft.description(), - "categoryKey", validatedDraft.categoryKey(), - "routingType", validatedDraft.routingType(), - "httpApiName", validatedDraft.httpApiName(), - "inputFields", validatedDraft.inputFields(), - "outputFields", validatedDraft.outputFields())); + return ResponseEntity.ok(validatedDraft); } catch (Exception e) { return ResponseEntity.internalServerError().body(Map.of("error", "AI Tool 초안 생성 실패: " + safeMessage(e))); } @@ -219,6 +224,17 @@ public class ScaffoldingController { return objectMapper.readValue(source, new TypeReference>() { }); } + private List parseDelimited(String source) { + if (source == null || source.isBlank()) { + return List.of(); + } + return Arrays.stream(source.split("[\\r\\n,]+")) + .map(String::trim) + .filter(value -> !value.isBlank()) + .distinct() + .toList(); + } + private List validateFields(List source) { if (source == null || source.isEmpty()) { throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다."); @@ -268,10 +284,37 @@ public class ScaffoldingController { if (title.isBlank() || description.isBlank()) { throw new IllegalArgumentException("AI가 Tool 제목 또는 설명을 생성하지 않았습니다."); } + String functionDescription = textOrDefault(draft.functionDescription(), description); + String displayDescription = textOrDefault(draft.displayDescription(), title); + String whenToUse = textOrDefault(draft.whenToUse(), description + " 요청을 처리할 때 사용한다."); + String whenNotToUse = textOrDefault(draft.whenNotToUse(), "필수 입력값이 없거나 다른 업무 요청에는 사용하지 않는다."); + String ioLimits = textOrDefault(draft.ioLimits(), "정의된 입력 필드만 허용하며 정의된 응답 DTO 범위만 반환한다."); + List exampleQueries = normalizedDraftList(draft.exampleQueries(), List.of( + title + " 해줘", title + " 정보를 알려줘", title + " 결과를 확인해줘")); + List tags = normalizedDraftList(draft.tags(), List.of(categoryKey)); + String ownerOrg = textOrDefault(draft.ownerOrg(), "MCP_TOOL"); return new ToolDraft(draft.baseName().trim(), title, description, categoryKey, routingType, httpApiName, + functionDescription, displayDescription, whenToUse, whenNotToUse, ioLimits, + exampleQueries, tags, ownerOrg, validateFields(draft.inputFields()), validateFields(draft.outputFields())); } + private String textOrDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private List normalizedDraftList(List values, List fallback) { + if (values == null) { + return fallback; + } + List normalized = values.stream() + .filter(value -> value != null && !value.isBlank()) + .map(String::trim) + .distinct() + .toList(); + return normalized.isEmpty() ? fallback : normalized; + } + private String generateAiContent(String prompt, String requestedModel) { return chatClientBuilder.build().prompt() .user(prompt) @@ -306,7 +349,10 @@ public class ScaffoldingController { } private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType, - String httpApiName, List inputFields, + String httpApiName, String functionDescription, String displayDescription, + String whenToUse, String whenNotToUse, String ioLimits, + List exampleQueries, List tags, String ownerOrg, + List inputFields, List outputFields) { } } diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSpecificationFactory.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSpecificationFactory.java index f4e3a3d3..00cb73f7 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSpecificationFactory.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSpecificationFactory.java @@ -16,6 +16,7 @@ package io.shinhanlife.dap.mcg.sync; * */ import io.shinhanlife.dap.mcc.dto.ToolMetadata; +import io.shinhanlife.dap.lib.mcp.ToolMetadataMcpMapper; import io.shinhanlife.dap.mcg.service.ExecuteService; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; @@ -48,18 +49,7 @@ public class RegistryMcpToolSpecificationFactory { * Registry Entry 하나를 MCP SDK의 stateless sync Tool specification으로 변환합니다. */ public McpServerFeatures.SyncToolSpecification create(ToolMetadata entry) { - McpSchema.Tool tool = McpSchema.Tool.builder() - .name(entry.getName()) - .description(description(entry)) - .inputSchema(toJsonSchema(inputSchema(entry))) - .annotations(new McpSchema.ToolAnnotations( - entry.getDisplayName(), - entry.getReadOnlyHint(), - entry.getDestructiveHint(), - entry.getIdempotentHint(), - entry.getOpenWorldHint(), - null)) - .build(); + McpSchema.Tool tool = ToolMetadataMcpMapper.toTool(entry); return McpServerFeatures.SyncToolSpecification.builder() .tool(tool) diff --git a/dap-gateway/src/main/resources/static/admin/scaffold.html b/dap-gateway/src/main/resources/static/admin/scaffold.html index 2f66d183..dfdf4e89 100644 --- a/dap-gateway/src/main/resources/static/admin/scaffold.html +++ b/dap-gateway/src/main/resources/static/admin/scaffold.html @@ -868,6 +868,43 @@
LLM call guidance: purpose, when to use it, required conditions, and exclusions.
+ +
+
Tool Schema V17 Metadata
+
LLM이 Tool을 올바르게 선택하도록 기능·사용 조건·제외 조건·입출력 제한을 분리해 입력합니다.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
@@ -1692,6 +1729,16 @@ form.elements.categoryKey.value = result.categoryKey || ''; form.elements.routingType.value = result.routingType || 'HTTP'; form.elements.httpApiName.value = result.httpApiName || ''; + form.elements.functionDescription.value = result.functionDescription || ''; + form.elements.displayDescription.value = result.displayDescription || ''; + form.elements.whenToUse.value = result.whenToUse || ''; + form.elements.whenNotToUse.value = result.whenNotToUse || ''; + form.elements.ioLimits.value = result.ioLimits || ''; + form.elements.exampleQueries.value = Array.isArray(result.exampleQueries) + ? result.exampleQueries.join('\n') : (result.exampleQueries || ''); + form.elements.tags.value = Array.isArray(result.tags) + ? result.tags.join(', ') : (result.tags || ''); + form.elements.ownerOrg.value = result.ownerOrg || 'MCP_TOOL'; document.getElementById('inputFields').value = JSON.stringify(result.inputFields || [], null, 2); document.getElementById('outputFields').value = JSON.stringify(result.outputFields || [], null, 2); alert('Tool 초안을 채웠습니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 입력해주세요.'); diff --git a/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactoryTest.java b/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactoryTest.java index bb6d5a40..f7d97391 100644 --- a/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactoryTest.java +++ b/dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactoryTest.java @@ -1,12 +1,15 @@ package io.shinhanlife.dap.biz.mcp.gateway.sync; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcg.sync.RegistryMcpToolSpecificationFactory; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; class RegistryMcpToolSpecificationFactoryTest { @@ -15,7 +18,15 @@ class RegistryMcpToolSpecificationFactoryTest { void exposesMetadataBehaviorHintsInMcpToolSpecification() { ToolMetadata metadata = ToolMetadata.builder() .name("customer_lookup") + .displayName("고객 조회") .description("Looks up customer information") + .displayDescription("고객 기본 정보 조회") + .semver("1.2.0") + .categoryKey("cus") + .exampleQueries(List.of("고객 10001을 조회해줘")) + .tags(List.of("customer", "search")) + .ownerOrg("CUSTOMER_TEAM") + .outputSchema(Map.of("type", "object", "properties", Map.of())) .readOnlyHint(true) .destructiveHint(false) .idempotentHint(true) @@ -29,5 +40,9 @@ class RegistryMcpToolSpecificationFactoryTest { assertFalse(tool.annotations().destructiveHint()); assertTrue(tool.annotations().idempotentHint()); assertFalse(tool.annotations().openWorldHint()); + assertEquals("고객 조회", tool.title()); + assertEquals("object", tool.outputSchema().get("type")); + assertEquals("1.2.0", tool.meta().get("version")); + assertEquals("CUSTOMER_TEAM", tool.meta().get("owner_org")); } } diff --git a/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingControllerToolDraftTest.java b/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingControllerToolDraftTest.java index 375387ae..14fd1dc9 100644 --- a/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingControllerToolDraftTest.java +++ b/dap-gateway/src/test/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingControllerToolDraftTest.java @@ -31,7 +31,7 @@ class ScaffoldingControllerToolDraftTest { when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec); when(requestSpec.call()).thenReturn(responseSpec); when(responseSpec.content()).thenReturn(""" - {"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","inputFields":[{"name":"customerId","type":"String","description":"고객번호","example":"C123","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","example":"SUCCESS","required":true}]} + {"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","functionDescription":"고객 계약의 현재 상태를 조회한다.","displayDescription":"고객 계약 상태 조회","whenToUse":"고객번호로 계약 상태 확인을 요청할 때 사용한다.","whenNotToUse":"계약 변경 또는 해지를 요청할 때는 사용하지 않는다.","ioLimits":"고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.","exampleQueries":["고객 C123의 계약 상태를 알려줘","C123 계약이 정상인지 확인해줘","고객번호 C123 계약 조회해줘"],"tags":["contract","status","search"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"customerId","type":"String","description":"고객번호","example":"C123","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","example":"SUCCESS","required":true}]} """); MockMvc mockMvc = MockMvcBuilders.standaloneSetup( new ScaffoldingController(builder, new ObjectMapper())) @@ -43,6 +43,14 @@ class ScaffoldingControllerToolDraftTest { .content("{\"description\":\"고객번호로 계약 상태를 조회\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.baseName").value("CustomerContractStatus")) + .andExpect(jsonPath("$.functionDescription").value("고객 계약의 현재 상태를 조회한다.")) + .andExpect(jsonPath("$.displayDescription").value("고객 계약 상태 조회")) + .andExpect(jsonPath("$.whenToUse").value("고객번호로 계약 상태 확인을 요청할 때 사용한다.")) + .andExpect(jsonPath("$.whenNotToUse").value("계약 변경 또는 해지를 요청할 때는 사용하지 않는다.")) + .andExpect(jsonPath("$.ioLimits").value("고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.")) + .andExpect(jsonPath("$.exampleQueries[0]").value("고객 C123의 계약 상태를 알려줘")) + .andExpect(jsonPath("$.tags[0]").value("contract")) + .andExpect(jsonPath("$.ownerOrg").value("MCP_TOOL")) .andExpect(jsonPath("$.inputFields[0].name").value("customerId")); } } diff --git a/dap-was-lib/build.gradle b/dap-was-lib/build.gradle index 9a2774c0..d4d66136 100644 --- a/dap-was-lib/build.gradle +++ b/dap-was-lib/build.gradle @@ -38,6 +38,7 @@ dependencies { // MCI XML 전문, JSON Tool Schema/Manifest 처리에 사용합니다. api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1' + api 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1' api 'com.fasterxml.jackson.core:jackson-databind:2.17.1' api 'com.networknt:json-schema-validator:3.0.0' diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestItem.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestItem.java index f03c9a96..9f07eb9f 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestItem.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestItem.java @@ -10,6 +10,7 @@ public record ToolManifestItem( String title, String description, Map inputSchema, + Map outputSchema, ToolManifestAnnotations annotations, @JsonProperty("_meta") ToolManifestMeta meta) { -} \ No newline at end of file +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestMeta.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestMeta.java index 192ba496..f21ba07e 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestMeta.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestMeta.java @@ -1,5 +1,15 @@ package io.shinhanlife.dap.lib.manifest; +import java.util.List; + /** Operational metadata exposed by the Tool Service manifest. */ -public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) { -} \ No newline at end of file +public record ToolManifestMeta( + String version, + long timeoutMillis, + boolean enabled, + List exampleQueries, + List tags, + String legacyInterfaceId, + List requiredEnvKeys, + String ownerOrg) { +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestService.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestService.java index 5050a442..c76a4726 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestService.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestService.java @@ -61,12 +61,14 @@ public class ToolManifestService { ? tool.getName() : tool.getDisplayName(); Map schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema(); return new ToolManifestItem( - tool.getName(), endpoint(tool), title, tool.getDescription(), schema, + tool.getName(), endpoint(tool), title, tool.getDescription(), schema, tool.getOutputSchema(), new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()), isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())), new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"), tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(), - tool.getEnabled() == null || tool.getEnabled())); + tool.getEnabled() == null || tool.getEnabled(), + defaultList(tool.getExampleQueries()), defaultList(tool.getTags()), + tool.getMciServiceId(), defaultList(tool.getRequiredEnvKeys()), tool.getOwnerOrg())); } private void validate(List tools) { @@ -128,4 +130,8 @@ public class ToolManifestService { private String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; } -} \ No newline at end of file + + private List defaultList(List value) { + return value == null ? List.of() : List.copyOf(value); + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolMetadataMcpMapper.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolMetadataMcpMapper.java new file mode 100644 index 00000000..8624e60e --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolMetadataMcpMapper.java @@ -0,0 +1,73 @@ +package io.shinhanlife.dap.lib.mcp; + +import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dap.mcc.dto.ToolMetadata; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Registry 메타데이터를 MCP SDK Tool 명세로 일관되게 변환합니다. */ +public final class ToolMetadataMcpMapper { + private ToolMetadataMcpMapper() { + } + + public static McpSchema.Tool toTool(ToolMetadata metadata) { + McpSchema.Tool.Builder builder = McpSchema.Tool.builder() + .name(metadata.getName()) + .title(defaultText(metadata.getDisplayName(), metadata.getName())) + .description(defaultText(metadata.getDescription(), metadata.getName() + " Tool")) + .inputSchema(toJsonSchema(metadata.getParametersSchema())) + .annotations(new McpSchema.ToolAnnotations( + metadata.getDisplayName(), metadata.getReadOnlyHint(), metadata.getDestructiveHint(), + metadata.getIdempotentHint(), metadata.getOpenWorldHint(), null)) + .meta(meta(metadata)); + if (metadata.getOutputSchema() != null && !metadata.getOutputSchema().isEmpty()) { + builder.outputSchema(metadata.getOutputSchema()); + } + return builder.build(); + } + + public static Map meta(ToolMetadata metadata) { + Map meta = new LinkedHashMap<>(); + put(meta, "version", metadata.getSemver()); + put(meta, "category_key", metadata.getCategoryKey()); + put(meta, "display_description", metadata.getDisplayDescription()); + put(meta, "example_queries", metadata.getExampleQueries()); + put(meta, "tags", metadata.getTags()); + put(meta, "legacy_interface_id", metadata.getMciServiceId()); + put(meta, "required_env_keys", metadata.getRequiredEnvKeys()); + put(meta, "owner_org", metadata.getOwnerOrg()); + return Map.copyOf(meta); + } + + private static void put(Map target, String key, Object value) { + if (value instanceof String text && !text.isBlank()) { + target.put(key, text); + } else if (value instanceof List list && !list.isEmpty()) { + target.put(key, List.copyOf(list)); + } + } + + private static String defaultText(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + @SuppressWarnings("unchecked") + private static McpSchema.JsonSchema toJsonSchema(Map source) { + Map schema = source == null ? emptySchema() : source; + return new McpSchema.JsonSchema( + String.valueOf(schema.getOrDefault("type", "object")), + schema.get("properties") instanceof Map properties + ? (Map) properties : Map.of(), + schema.get("required") instanceof List required ? (List) required : List.of(), + schema.get("additionalProperties") instanceof Boolean additionalProperties + ? additionalProperties : Boolean.TRUE, + schema.get("$defs") instanceof Map defs ? (Map) defs : Map.of(), + schema.get("definitions") instanceof Map definitions + ? (Map) definitions : Map.of()); + } + + private static Map emptySchema() { + return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false); + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java index 9d327e5a..18d25b4d 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java @@ -39,18 +39,7 @@ public class ToolPodMcpToolSynchronizer { } private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) { - McpSchema.Tool mcpTool = McpSchema.Tool.builder() - .name(tool.getName()) - .description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription()) - .inputSchema(toJsonSchema(tool.getParametersSchema())) - .annotations(new McpSchema.ToolAnnotations( - tool.getDisplayName(), - tool.getReadOnlyHint(), - tool.getDestructiveHint(), - tool.getIdempotentHint(), - tool.getOpenWorldHint(), - null)) - .build(); + McpSchema.Tool mcpTool = ToolMetadataMcpMapper.toTool(tool); return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool) .callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build(); } diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java index 3744cb07..74d29baa 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java @@ -21,6 +21,8 @@ import io.shinhanlife.dap.lib.annotation.ToolHint; import io.shinhanlife.dap.lib.config.McpProperties; import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.lib.util.ToolSchemaResolver; +import io.shinhanlife.dap.lib.metadata.ToolDefinition; +import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository; import jakarta.annotation.PostConstruct; import java.lang.reflect.Method; import java.util.ArrayList; @@ -29,14 +31,15 @@ import java.util.List; import java.util.Map; import java.util.UUID; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -49,7 +52,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @Component @Configuration @EnableScheduling -@RequiredArgsConstructor @ConditionalOnBean(McpToolExecutionService.class) public class ToolRegistryHeartbeatSender { @@ -58,6 +60,23 @@ public class ToolRegistryHeartbeatSender { private final McpProperties mcpProperties; private final RestClient restClient = RestClient.create(); private final ToolSchemaResolver toolSchemaResolver; + private final ToolDefinitionRepository toolDefinitionRepository; + + @Autowired + public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper, + McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver, + @Nullable ToolDefinitionRepository toolDefinitionRepository) { + this.applicationContext = applicationContext; + this.objectMapper = objectMapper; + this.mcpProperties = mcpProperties; + this.toolSchemaResolver = toolSchemaResolver; + this.toolDefinitionRepository = toolDefinitionRepository; + } + + public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper, + McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver) { + this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null); + } @Value("${axhub.gateway.url:http://localhost:8081}") private String gatewayUrl; @@ -143,11 +162,18 @@ public class ToolRegistryHeartbeatSender { // TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction Map finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType); meta.setParametersSchema(finalSchema); + Map outputSchema = toolSchemaResolver.resolveOutput( + functionAnnotation, method.getReturnType(), hintAnnotation); + if (!outputSchema.isEmpty()) { + meta.setOutputSchema(outputSchema); + } } catch (Exception e) { log.error("Failed to generate schema for {}", subToolName, e); } } + enrichWithDefinition(meta, rawSubToolName, hintAnnotation); + if (isRegister) { registeredTools.add(meta); } @@ -158,6 +184,45 @@ public class ToolRegistryHeartbeatSender { } } + private void enrichWithDefinition(ToolMetadata meta, String rawToolName, ToolHint hintAnnotation) { + if (toolDefinitionRepository == null) { + return; + } + toolDefinitionRepository.findByName(rawToolName) + .ifPresent(definition -> applyDefinition(meta, definition, hintAnnotation)); + } + + private void applyDefinition(ToolMetadata meta, ToolDefinition definition, ToolHint hintAnnotation) { + meta.setDisplayName(definition.displayName()); + meta.setSemver(definition.version()); + meta.setCategoryKey(definition.categoryKey()); + meta.setFunctionDescription(definition.description().function()); + meta.setWhenToUse(definition.description().whenToUse()); + meta.setWhenNotToUse(definition.description().whenNotToUse()); + meta.setIoLimits(definition.description().ioLimits()); + meta.setDescription(String.join("\n", definition.description().function(), + "사용 시점: " + definition.description().whenToUse(), + "사용 제외: " + definition.description().whenNotToUse(), + "입출력 제한: " + definition.description().ioLimits())); + meta.setDisplayDescription(definition.displayDescription()); + meta.setExampleQueries(definition.exampleQueries()); + meta.setReadOnlyHint(definition.readOnly()); + meta.setDestructiveHint(definition.destructive()); + meta.setIdempotentHint(definition.idempotent()); + boolean explicitInputResource = hintAnnotation != null && !hintAnnotation.inputSchemaResource().isBlank(); + boolean explicitOutputResource = hintAnnotation != null && !hintAnnotation.outputSchemaResource().isBlank(); + if (!explicitInputResource) { + meta.setParametersSchema(definition.parametersSchema()); + } + if (!explicitOutputResource && definition.outputSchema() != null && !definition.outputSchema().isEmpty()) { + meta.setOutputSchema(definition.outputSchema()); + } + meta.setTags(definition.tags()); + meta.setMciServiceId(definition.legacyInterfaceId()); + meta.setRequiredEnvKeys(definition.requiredEnvKeys()); + meta.setOwnerOrg(definition.ownerOrg()); + } + @Scheduled(fixedRate = 30000) public void sendHeartbeats() { if (registeredTools.isEmpty()) return; diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinition.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinition.java new file mode 100644 index 00000000..f5233e1f --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinition.java @@ -0,0 +1,37 @@ +package io.shinhanlife.dap.lib.metadata; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** BC-DAB-STD-003 V17 Tool 정의 파일의 불변 모델입니다. */ +public record ToolDefinition( + String name, + @JsonProperty("display_name") String displayName, + String version, + @JsonProperty("category_key") String categoryKey, + ToolDescription description, + @JsonProperty("display_description") String displayDescription, + @JsonProperty("example_queries") List exampleQueries, + @JsonProperty("read_only") Boolean readOnly, + Boolean destructive, + Boolean idempotent, + @JsonProperty("parameters_schema") Map parametersSchema, + @JsonProperty("output_schema") Map outputSchema, + List tags, + @JsonProperty("legacy_interface_id") String legacyInterfaceId, + @JsonProperty("required_env_keys") List requiredEnvKeys, + @JsonProperty("owner_org") String ownerOrg) { + + public ToolDefinition withExampleQueries(List queries) { + return new ToolDefinition(name, displayName, version, categoryKey, description, displayDescription, + queries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId, + requiredEnvKeys, ownerOrg); + } + + public ToolDefinition withName(String value) { + return new ToolDefinition(value, displayName, version, categoryKey, description, displayDescription, + exampleQueries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId, + requiredEnvKeys, ownerOrg); + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepository.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepository.java new file mode 100644 index 00000000..d7b42d3f --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepository.java @@ -0,0 +1,62 @@ +package io.shinhanlife.dap.lib.metadata; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Component; + +/** classpath의 Tool 정의를 기동 시 한 번 읽어 name 기준으로 캐시합니다. */ +@Component +public class ToolDefinitionRepository { + public static final String DEFAULT_LOCATION = "classpath*:tool-definitions/**/*.yml"; + + private final Map definitions; + + @Autowired + public ToolDefinitionRepository(ResourceLoader resourceLoader) { + this(new ObjectMapper(new YAMLFactory()), resourceLoader, DEFAULT_LOCATION); + } + + public ToolDefinitionRepository(ObjectMapper yamlMapper, ResourceLoader resourceLoader, String location) { + this(yamlMapper, new PathMatchingResourcePatternResolver(resourceLoader), location); + } + + private ToolDefinitionRepository(ObjectMapper yamlMapper, ResourcePatternResolver resolver, String location) { + this.definitions = Collections.unmodifiableMap(load(yamlMapper, resolver, location)); + } + + public Optional findByName(String name) { + return Optional.ofNullable(definitions.get(name)); + } + + public Map findAll() { + return definitions; + } + + private Map load(ObjectMapper mapper, ResourcePatternResolver resolver, String location) { + Map loaded = new LinkedHashMap<>(); + try { + for (Resource resource : resolver.getResources(location)) { + ToolDefinition definition = mapper.readValue(resource.getInputStream(), ToolDefinition.class); + String source = resource.getDescription(); + ToolDefinitionValidator.validate(definition, source); + ToolDefinition previous = loaded.putIfAbsent(definition.name(), definition); + if (previous != null) { + throw new IllegalStateException("Duplicate Tool definition name: " + definition.name()); + } + } + return loaded; + } catch (IOException exception) { + throw new IllegalStateException("Failed to load Tool definitions from " + location, exception); + } + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionValidator.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionValidator.java new file mode 100644 index 00000000..6137ac1e --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionValidator.java @@ -0,0 +1,84 @@ +package io.shinhanlife.dap.lib.metadata; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** Tool 정의가 BC-DAB-STD-003 V17 필수 규칙을 만족하는지 검증합니다. */ +public final class ToolDefinitionValidator { + private static final Pattern TOOL_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,63}$"); + + private ToolDefinitionValidator() { + } + + public static void validate(ToolDefinition definition, String source) { + if (definition == null) { + fail(source, "definition", "문서가 비어 있습니다"); + } + if (isBlank(definition.name()) || !TOOL_NAME.matcher(definition.name()).matches()) { + fail(source, "name", "^[a-z][a-z0-9_]{2,63}$ 형식이어야 합니다"); + } + requireText(source, "display_name", definition.displayName()); + requireText(source, "version", definition.version()); + requireText(source, "category_key", definition.categoryKey()); + requireText(source, "display_description", definition.displayDescription()); + if (definition.description() == null) { + fail(source, "description", "필수입니다"); + } + requireText(source, "description.function", definition.description().function()); + requireText(source, "description.when_to_use", definition.description().whenToUse()); + requireText(source, "description.when_not_to_use", definition.description().whenNotToUse()); + requireText(source, "description.io_limits", definition.description().ioLimits()); + List examples = definition.exampleQueries(); + if (examples == null || examples.size() < 3 || examples.size() > 10 + || examples.stream().anyMatch(ToolDefinitionValidator::isBlank)) { + fail(source, "example_queries", "비어 있지 않은 자연어 질의가 3~10개 필요합니다"); + } + if (examples.stream().anyMatch(query -> query.contains(definition.name()))) { + fail(source, "example_queries", "Tool name을 직접 포함할 수 없습니다"); + } + if (definition.readOnly() == null || definition.destructive() == null || definition.idempotent() == null) { + fail(source, "annotations", "read_only, destructive, idempotent는 필수입니다"); + } + validateSchema(definition.parametersSchema(), source); + if (definition.outputSchema() != null && !definition.outputSchema().isEmpty()) { + validateSchema(definition.outputSchema(), source + " output_schema"); + } + } + + @SuppressWarnings("unchecked") + private static void validateSchema(Map schema, String source) { + if (schema == null || !"object".equals(schema.get("type"))) { + fail(source, "parameters_schema.type", "object여야 합니다"); + } + if (!Boolean.FALSE.equals(schema.get("additionalProperties"))) { + fail(source, "parameters_schema.additionalProperties", "false여야 합니다"); + } + Object propertiesValue = schema.get("properties"); + if (!(propertiesValue instanceof Map)) { + fail(source, "parameters_schema.properties", "object여야 합니다"); + } + Map properties = (Map) propertiesValue; + for (Map.Entry entry : properties.entrySet()) { + if (!(entry.getValue() instanceof Map property) + || isBlank(String.valueOf(property.containsKey("description") + ? property.get("description") : ""))) { + fail(source, "parameters_schema.properties." + entry.getKey() + ".description", "필수입니다"); + } + } + } + + private static void requireText(String source, String field, String value) { + if (isBlank(value)) { + fail(source, field, "필수입니다"); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static void fail(String source, String field, String message) { + throw new IllegalStateException("Invalid Tool definition [" + source + "] " + field + ": " + message); + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDescription.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDescription.java new file mode 100644 index 00000000..5fb84183 --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDescription.java @@ -0,0 +1,11 @@ +package io.shinhanlife.dap.lib.metadata; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** LLM이 Tool 선택 여부를 판단할 때 사용하는 V17 목적 설명입니다. */ +public record ToolDescription( + String function, + @JsonProperty("when_to_use") String whenToUse, + @JsonProperty("when_not_to_use") String whenNotToUse, + @JsonProperty("io_limits") String ioLimits) { +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/PodScaffolder.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/PodScaffolder.java index 09bf3a8e..64125ddd 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/PodScaffolder.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/PodScaffolder.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; import java.nio.file.StandardOpenOption; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -72,7 +73,7 @@ public class PodScaffolder { implementation project(':dap-was-lib') } """; - Files.writeString(modulePath.resolve("build.gradle"), buildGradle); + writeUtf8(modulePath.resolve("build.gradle"), buildGradle); log.append("[3/6] Dockerfile 생성 중...\n"); String dockerfile = """ @@ -84,7 +85,7 @@ public class PodScaffolder { EXPOSE %s ENTRYPOINT ["java", "-jar", "app.jar"] """.formatted(moduleName, portStr); - Files.writeString(modulePath.resolve("Dockerfile"), dockerfile); + writeUtf8(modulePath.resolve("Dockerfile"), dockerfile); log.append("[4/6] Application 클래스 및 설정 파일 생성 중...\n"); Path srcPath = modulePath.resolve("src/main/java/io/shinhanlife/dap/mcc/" + shortName); @@ -121,7 +122,7 @@ public class PodScaffolder { } } """.formatted(shortName, shortName, capitalize(shortName), author, createDate, createDate, author, capitalize(shortName), capitalize(shortName)); - Files.writeString(srcPath.resolve("DapWas" + capitalize(shortName) + "Application.java"), appClass); + writeUtf8(srcPath.resolve("DapWas" + capitalize(shortName) + "Application.java"), appClass); Path resPath = modulePath.resolve("src/main/resources"); Files.createDirectories(resPath); @@ -145,7 +146,7 @@ public class PodScaffolder { tenant-domains: TESTER-DEV: ALL """.formatted(portStr, moduleName, moduleName.replace("dap-", "")); - Files.writeString(resPath.resolve("application.yml"), applicationYml); + writeUtf8(resPath.resolve("application.yml"), applicationYml); String applicationLocalYml = """ # Local 환경 전용 설정 (H2 메모리 DB 등) @@ -194,7 +195,7 @@ public class PodScaffolder { tool: url: ${AXHUB_TOOL_URL:http://localhost:${server.port}} """; - Files.writeString(resPath.resolve("application-local.yml"), applicationLocalYml); + writeUtf8(resPath.resolve("application-local.yml"), applicationLocalYml); String applicationDevYml = """ # OCI 클라우드 환경 전용 설정 @@ -244,7 +245,7 @@ public class PodScaffolder { externalMci: url: http://10.176.32.176 """.formatted(portStr, portStr); - Files.writeString(resPath.resolve("application-dev.yml"), applicationDevYml); + writeUtf8(resPath.resolve("application-dev.yml"), applicationDevYml); String applicationTestYml = """ server: @@ -264,7 +265,7 @@ public class PodScaffolder { tool: url: ${AXHUB_TOOL_URL} """.formatted(portStr); - Files.writeString(resPath.resolve("application-test.yml"), applicationTestYml); + writeUtf8(resPath.resolve("application-test.yml"), applicationTestYml); String applicationProdYml = """ server: @@ -284,7 +285,7 @@ public class PodScaffolder { tool: url: ${AXHUB_TOOL_URL} """.formatted(portStr); - Files.writeString(resPath.resolve("application-prod.yml"), applicationProdYml); + writeUtf8(resPath.resolve("application-prod.yml"), applicationProdYml); String logbackXml = """ @@ -312,21 +313,21 @@ public class PodScaffolder { """.formatted(moduleName, moduleName); - Files.writeString(resPath.resolve("logback-spring.xml"), logbackXml); + writeUtf8(resPath.resolve("logback-spring.xml"), logbackXml); log.append("[5/6] settings.gradle 에 모듈 등록 중...\n"); Path settingsPath = rootDir.resolve(Paths.get("settings.gradle")); if (Files.exists(settingsPath)) { - String settings = Files.readString(settingsPath); + String settings = Files.readString(settingsPath, StandardCharsets.UTF_8); if (!settings.contains("include '" + moduleName + "'")) { - Files.writeString(settingsPath, System.lineSeparator() + "include '" + moduleName + "'" + System.lineSeparator(), StandardOpenOption.APPEND); + Files.writeString(settingsPath, System.lineSeparator() + "include '" + moduleName + "'" + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.APPEND); } } log.append("[6/6] docker-compose.yml 에 서비스 추가 중...\n"); Path dockerComposePath = rootDir.resolve(Paths.get("docker-compose.yml")); if (Files.exists(dockerComposePath)) { - String compose = Files.readString(dockerComposePath); + String compose = Files.readString(dockerComposePath, StandardCharsets.UTF_8); String serviceName = moduleName.replace("dap-", ""); // e.g. tool-payment if (!compose.contains(" " + serviceName + ":")) { String newService = """ @@ -352,7 +353,7 @@ public class PodScaffolder { - GLOW_COMMUNICATION_EAI_HOST=http://mci-mock - GLOW_COMMUNICATION_EAI_PORT=8080 """.formatted(serviceName, moduleName, portStr, portStr, serviceName, portStr); - Files.writeString(dockerComposePath, System.lineSeparator() + newService, StandardOpenOption.APPEND); + Files.writeString(dockerComposePath, System.lineSeparator() + newService, StandardCharsets.UTF_8, StandardOpenOption.APPEND); } } @@ -365,6 +366,10 @@ public class PodScaffolder { return log.toString(); } + private static void writeUtf8(Path path, String content) throws IOException { + Files.writeString(path, content, StandardCharsets.UTF_8); + } + private static String capitalize(String str) { if (str == null || str.isEmpty()) return str; return str.substring(0, 1).toUpperCase() + str.substring(1); diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java index d1658a26..d6da6dd5 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.LinkedHashSet; @@ -14,28 +15,28 @@ import java.util.Set; import java.util.Scanner; /** - * MCP Tool 肄붾뱶瑜??먮룞 ?앹꽦(Scaffolding)?섎뒗 ?좏떥由ы떚 ?대옒?? + * MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스입니다. * - * [?ㅽ뻾 諛⑸쾿] - * 諛⑸쾿 1. IDE(IntelliJ ???먯꽌 吏곸젒 ?ㅽ뻾 (?€?뷀삎 紐⑤뱶 異붿쿇 狩? - * - ???대옒??ToolScaffolder.java)瑜??닿퀬 main 硫붿꽌?쒕? 吏곸젒 ?ㅽ뻾(Run)?⑸땲?? - * - 肄섏넄 李쎌뿉 ?⑤뒗 吏덈Ц??李⑤??€濡?媛믪쓣 ?낅젰?섍린留??섎㈃ ?뚯씪???앹꽦?⑸땲?? + * [실행 방법] + * 방법 1. IDE(IntelliJ 등)에서 직접 실행 + * - ToolScaffolder.java의 main 메서드를 실행합니다. + * - 콘솔 질문에 차례대로 값을 입력하면 파일이 생성됩니다. * - * 諛⑸쾿 2. 而ㅻ㎤?쒕씪???곕????먯꽌 ?ㅽ뻾 (紐낅졊??湲곕컲) - * - 而댄뙆?? javac -encoding UTF-8 dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/util/ToolScaffolder.java - * - ?ㅽ뻾: java -cp dap-was-lib/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder [?대쫫] [ID] "[?ㅻ챸]" "[洹몃9]" "[?듭떊諛⑹떇]" "[紐⑤뱢紐?" + * 방법 2. 명령줄에서 실행 + * - 컴파일: javac -encoding UTF-8 dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java + * - 실행: java -cp dap-was-lib/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder [이름] [ID] "[설명]" "[그룹]" "[통신방식]" "[모듈명]" */ /** * @package io.shinhanlife.dap.lib.util * @className ToolScaffolder - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author 0986406 * @create 2026.09.01 *
- * ---------- 媛쒖젙?대젰 ----------
- * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
  * ---------- -------- ---------------------------
- * 2026.09.01  0986406    理쒖큹?앹꽦
+ * 2026.09.01  0986406    최초생성
  * 
  * 
*/ @@ -47,6 +48,17 @@ public class ToolScaffolder { public record FieldDefinition(String name, String type, String description, String example, boolean required) { } + public record ToolDefinitionOptions( + String functionDescription, + String whenToUse, + String whenNotToUse, + String ioLimits, + String displayDescription, + List exampleQueries, + List tags, + String ownerOrg) { + } + public static void main(String[] args) throws IOException { Scanner scanner = new Scanner(System.in); @@ -54,8 +66,8 @@ public class ToolScaffolder { System.out.println(" MCP Tool Scaffolder (Java CLI) "); System.out.println("=========================================\n"); - String baseName = getOrAsk(args, 0, scanner, "1. ?앹꽦??Tool??湲곕낯 ?대쫫 (?? ExchangeRate) [?곷Ц PascalCase]: "); - String interfaceId = getOrAsk(args, 1, scanner, "2. ?덇굅??API ?명꽣?섏씠??ID (?? EXCH_001): "); + String baseName = getOrAsk(args, 0, scanner, "1. 생성할 Tool의 기본 이름 (예: ExchangeRate) [영문 PascalCase]: "); + String interfaceId = getOrAsk(args, 1, scanner, "2. 레거시 API 인터페이스 ID (예: EXCH_001): "); String title = getOrAsk(args, 2, scanner, "3. Tool title: "); String description = getOrAsk(args, 3, scanner, "4. Tool description for LLM: "); String group = getOrAsk(args, 4, scanner, "5. Tool category: "); @@ -72,12 +84,12 @@ public class ToolScaffolder { String defaultAuthor = System.getProperty("user.name"); String defaultDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd")); - String author = getOrAsk(args, 7, scanner, "7. ?묒꽦??(?뷀꽣 ?낅젰 ??'" + defaultAuthor + "'): "); + String author = getOrAsk(args, 7, scanner, "8. 작성자(Enter 입력 시 '" + defaultAuthor + "'): "); if (author.trim().isEmpty()) author = defaultAuthor; - String createDate = getOrAsk(args, 8, scanner, "8. ?묒꽦??(?뷀꽣 ?낅젰 ??'" + defaultDate + "'): "); + String createDate = getOrAsk(args, 8, scanner, "9. 작성일(Enter 입력 시 '" + defaultDate + "'): "); if (createDate.trim().isEmpty()) createDate = defaultDate; - String useSchemaResourceStr = getOrAsk(args, 9, scanner, "9. input/output JSON Schema ?뚯씪 ?먮룞 ?앹꽦 ?щ? (y/N): "); + String useSchemaResourceStr = getOrAsk(args, 9, scanner, "10. input/output JSON Schema 파일 자동 생성 여부 (y/N): "); boolean useSchemaResource = "y".equalsIgnoreCase(useSchemaResourceStr.trim()); String schemaResourceDirectory = "classpath:tool-schemas/" + group.toLowerCase() + "/"; @@ -125,6 +137,17 @@ public class ToolScaffolder { * Generates a Tool using an HTTP API name that is resolved from glow.communication.http.api-list. */ public static String scaffold(String baseName, String interfaceId, String title, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List inputFields, List outputFields, String httpApiName) throws IOException { + return scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, createDate, + register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields, + httpApiName, null); + } + + public static String scaffold(String baseName, String interfaceId, String title, String description, String group, + String routingType, String moduleName, String author, String createDate, + boolean register, String clientSystemCode, String inputSchemaResource, + String outputSchemaResource, List inputFields, + List outputFields, String httpApiName, + ToolDefinitionOptions definitionOptions) throws IOException { baseName = toPascalCase(baseName); title = title == null || title.isBlank() ? baseName : title.trim(); description = description == null ? "" : description.trim(); @@ -139,12 +162,13 @@ public class ToolScaffolder { Path legacyDtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "legacy")); Path converterDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "converter")); - // schema resource ?뚯씪 寃쎈줈 (useSchemaResource=true ???뚮쭔 ?앹꽦) + // Schema Resource 파일 경로(useSchemaResource=true일 때만 생성) boolean useSchemaResource = (inputSchemaResource != null && !inputSchemaResource.trim().isEmpty()) || (outputSchemaResource != null && !outputSchemaResource.trim().isEmpty()); String schemaBaseName = toKebabCase(baseName); String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json"; String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json"; Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-schemas", group.toLowerCase())); + Path definitionDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-definitions", group.toLowerCase())); String inputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + inputSchemaFileName; String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName; @@ -199,25 +223,25 @@ public class ToolScaffolder { /** * @package %s.dto * @className %sRequest - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-             * ---------- 媛쒖젙?대젰 ----------
-             * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+             * ---------- 개정이력 ----------
+             * 수정일       수정자     수정내용
              * ---------- -------- ---------------------------
-             * %s  %s    理쒖큹?앹꽦
+             * %s  %s    최초생성
              * 
              * 
*/ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class %sRequest { - @McpToolParam(description = "?섏떊???꾪솕踰덊샇", required = true) + @McpToolParam(description = "수신자 전화번호", required = true) -(?:\\\\d{3}|\\\\d{4})-\\\\d{4}$", examples = {"010-1234-5678"}) private String phoneNumber; - @McpToolParam(description = "?꾩넚??硫붿떆吏€ ?댁슜", required = true) + @McpToolParam(description = "전송할 메시지 내용", required = true) private String message; } """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); @@ -227,9 +251,9 @@ public class ToolScaffolder { .replaceAll("(?m)^\\s*@McpToolParam\\([^\\r\\n]*\\)\\R", "") .replaceAll("(?m)^\\s*-\\(\\?:[^\\r\\n]*\\R", "") .replace("private String phoneNumber;", "@Schema(example = \"01012345678\")\n private String phoneNumber;") - .replace("private String message;", "@Schema(example = \"?뚯뒪??硫붿떆吏€?낅땲??\")\n private String message;"); + .replace("private String message;", "@Schema(example = \"테스트 메시지입니다.\")\n private String message;"); reqContent = dtoContent(bizPackage + ".dto", baseName + "Request", inputFields, author, createDate, true); - Files.writeString(dtoDir.resolve(baseName + "Request.java"), reqContent); + writeUtf8(dtoDir.resolve(baseName + "Request.java"), reqContent); // Generate Response DTO String resContent = """ @@ -241,14 +265,14 @@ public class ToolScaffolder { /** * @package %s.dto * @className %sResponse - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-             * ---------- 媛쒖젙?대젰 ----------
-             * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+             * ---------- 개정이력 ----------
+             * 수정일       수정자     수정내용
              * ---------- -------- ---------------------------
-             * %s  %s    理쒖큹?앹꽦
+             * %s  %s    최초생성
              * 
              * 
*/ @@ -263,7 +287,7 @@ public class ToolScaffolder { } """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); resContent = dtoContent(bizPackage + ".dto", baseName + "Response", outputFields, author, createDate, false); - Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent); + writeUtf8(dtoDir.resolve(baseName + "Response.java"), resContent); String toolName = toToolName(moduleName, group, baseName); @@ -287,14 +311,14 @@ public class ToolScaffolder { /** * @package %s.usecase * @className %sUseCase - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-             * ---------- 媛쒖젙?대젰 ----------
-             * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+             * ---------- 개정이력 ----------
+             * 수정일       수정자     수정내용
              * ---------- -------- ---------------------------
-             * %s  %s    理쒖큹?앹꽦
+             * %s  %s    최초생성
              *
              * 
*/ @@ -315,7 +339,7 @@ public class ToolScaffolder { baseName, baseName ); - Files.writeString(usecaseDir.resolve(baseName + "UseCase.java"), serviceInterfaceContent); + writeUtf8(usecaseDir.resolve(baseName + "UseCase.java"), serviceInterfaceContent); String serviceImplContent; @@ -338,14 +362,14 @@ public class ToolScaffolder { /** * @package %s.usecase.impl * @className %sUseCaseImpl - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @@ -359,9 +383,9 @@ public class ToolScaffolder { @Override public %sResponse execute(%sRequest req) { - log.info("[MCI Tool] {} ?붿껌 ?섏떊.", "%s"); + log.info("[MCI Tool] {} 요청 수신.", "%s"); try { - // MapStruct瑜??댁슜???먮룞 留ㅽ븨 (AI DTO -> MCI DTO) + // MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO) %s_I mciReq = converter.toLegacyRequest(req); Transfer resTransfer = mci.callTo( @@ -377,7 +401,7 @@ public class ToolScaffolder { : "MCI call completed without a response body."); return response; } catch (Exception e) { - log.error("[MCI Tool] ?곕룞 以??ㅻ쪟 諛쒖깮: {}", e.getMessage(), e); + log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e); %sResponse response = new %sResponse(); response.setResultCode("ERROR"); response.setResultMessage(e.getMessage() != null ? e.getMessage() : "Unknown error"); @@ -437,14 +461,14 @@ public class ToolScaffolder { /** * @package %s.usecase.impl * @className %sUseCaseImpl - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @@ -490,7 +514,7 @@ public class ToolScaffolder { ); } - Files.writeString(usecaseImplDir.resolve(baseName + "UseCaseImpl.java"), serviceImplContent); + writeUtf8(usecaseImplDir.resolve(baseName + "UseCaseImpl.java"), serviceImplContent); if (isMci) { String mciReqContent = """ @@ -501,32 +525,32 @@ public class ToolScaffolder { /** * @package %s.%s.io * @className %s_I - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @Data public class %s_I { /** - * EAI ?쒖뒪?쒖씠 ?붽뎄?섎뒗 ?섏떊??踰덊샇 ?뚮씪誘명꽣紐? + * EAI 시스템이 요구하는 수신자 번호 파라미터명 */ private String phone; /** - * EAI ?쒖뒪?쒖씠 ?붽뎄?섎뒗 硫붿떆吏€ ?댁슜 ?뚮씪誘명꽣紐? + * EAI 시스템이 요구하는 메시지 내용 파라미터명 */ private String content; } """.formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId); mciReqContent = mciIoContent(mciGroupPath.replace("/", "."), interfaceId + "_I", inputFields, author, createDate); - Files.writeString(mciIoDir.resolve(interfaceId + "_I.java"), mciReqContent); + writeUtf8(mciIoDir.resolve(interfaceId + "_I.java"), mciReqContent); String mciResContent = """ package %s.%s.io; @@ -536,14 +560,14 @@ public class ToolScaffolder { /** * @package %s.%s.io * @className %s_O - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @@ -553,7 +577,7 @@ public class ToolScaffolder { } """.formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId); mciResContent = mciIoContent(mciGroupPath.replace("/", "."), interfaceId + "_O", outputFields, author, createDate); - Files.writeString(mciIoDir.resolve(interfaceId + "_O.java"), mciResContent); + writeUtf8(mciIoDir.resolve(interfaceId + "_O.java"), mciResContent); String converterContent = """ package %s.converter; @@ -569,14 +593,14 @@ public class ToolScaffolder { /** * @package %s.converter * @className %sConverter - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @@ -605,7 +629,7 @@ public class ToolScaffolder { baseName, interfaceId ); converterContent = mciConverterContent(bizPackage, baseName, mciGroupPath.replace("/", "."), interfaceId); - Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent); + writeUtf8(converterDir.resolve(baseName + "Converter.java"), converterContent); log.append("\n=========================================\n"); log.append(" Scaffolding Complete! (Routing: " + routingType + ")\n"); @@ -630,14 +654,14 @@ public class ToolScaffolder { /** * @package %s.%s * @className Mci%sClient - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                     * ---------- 媛쒖젙?대젰 ----------
-                     * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                     * ---------- 개정이력 ----------
+                     * 수정일       수정자     수정내용
                      * ---------- -------- ---------------------------
-                     * %s  %s    理쒖큹?앹꽦
+                     * %s  %s    최초생성
                      * 
                      * 
*/ @@ -654,7 +678,7 @@ public class ToolScaffolder { BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), clientPrefixCap, author, createDate, createDate, author, clientPrefixCap ); - Files.writeString(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java"), mciClientContent); + writeUtf8(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java"), mciClientContent); log.append("[MCI Client] ").append(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java")).append("\n"); } @@ -664,13 +688,13 @@ public class ToolScaffolder { String httpResponseClass = baseName + "HttpResponse"; String httpClientClass = httpApiClass + "Client"; - Files.writeString(httpIoDir.resolve(httpRequestClass + ".java"), + writeUtf8(httpIoDir.resolve(httpRequestClass + ".java"), dtoContent(httpPackage + ".io", httpRequestClass, inputFields, author, createDate, true)); - Files.writeString(httpIoDir.resolve(httpResponseClass + ".java"), + writeUtf8(httpIoDir.resolve(httpResponseClass + ".java"), dtoContent(httpPackage + ".io", httpResponseClass, outputFields, author, createDate, false)); - Files.writeString(httpClientDir.resolve(httpClientClass + ".java"), + writeUtf8(httpClientDir.resolve(httpClientClass + ".java"), httpClientContent(httpPackage, httpClientClass, httpApiName)); - Files.writeString(converterDir.resolve(baseName + "Converter.java"), + writeUtf8(converterDir.resolve(baseName + "Converter.java"), httpConverterContent(bizPackage, baseName, httpPackage)); log.append("\n=========================================\n"); @@ -693,32 +717,32 @@ public class ToolScaffolder { /** * @package %s.legacy * @className %sLegacyRequest - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @Data public class %sLegacyRequest { /** - * EAI ?쒖뒪?쒖씠 ?붽뎄?섎뒗 ?섏떊??踰덊샇 ?뚮씪誘명꽣紐? + * EAI 시스템이 요구하는 수신자 번호 파라미터명 */ private String phone; /** - * EAI ?쒖뒪?쒖씠 ?붽뎄?섎뒗 硫붿떆吏€ ?댁슜 ?뚮씪誘명꽣紐? + * EAI 시스템이 요구하는 메시지 내용 파라미터명 */ private String content; } """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); legacyReqContent = dtoContent(bizPackage + ".legacy", baseName + "LegacyRequest", inputFields, author, createDate, true); - Files.writeString(legacyDtoDir.resolve(baseName + "LegacyRequest.java"), legacyReqContent); + writeUtf8(legacyDtoDir.resolve(baseName + "LegacyRequest.java"), legacyReqContent); String legacyResContent = """ package %s.legacy; @@ -728,14 +752,14 @@ public class ToolScaffolder { /** * @package %s.legacy * @className %sLegacyResponse - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @@ -745,7 +769,7 @@ public class ToolScaffolder { } """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); legacyResContent = dtoContent(bizPackage + ".legacy", baseName + "LegacyResponse", outputFields, author, createDate, true); - Files.writeString(legacyDtoDir.resolve(baseName + "LegacyResponse.java"), legacyResContent); + writeUtf8(legacyDtoDir.resolve(baseName + "LegacyResponse.java"), legacyResContent); String converterContent = """ package %s.converter; @@ -761,14 +785,14 @@ public class ToolScaffolder { /** * @package %s.converter * @className %sConverter - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author %s * @create %s *
-                 * ---------- 媛쒖젙?대젰 ----------
-                 * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+                 * ---------- 개정이력 ----------
+                 * 수정일       수정자     수정내용
                  * ---------- -------- ---------------------------
-                 * %s  %s    理쒖큹?앹꽦
+                 * %s  %s    최초생성
                  * 
                  * 
*/ @@ -795,7 +819,7 @@ public class ToolScaffolder { baseName, baseName, baseName, baseName, baseName, baseName, baseName ); converterContent = legacyConverterContent(bizPackage, baseName); - Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent); + writeUtf8(converterDir.resolve(baseName + "Converter.java"), converterContent); log.append("\n=========================================\n"); log.append(" Scaffolding Complete! (Routing: " + routingType + ")\n"); @@ -808,7 +832,7 @@ public class ToolScaffolder { log.append("[Legacy Response DTO] ").append(legacyDtoDir.resolve(baseName + "LegacyResponse.java")).append("\n"); log.append("[Legacy Converter] ").append(converterDir.resolve(baseName + "Converter.java")).append("\n"); } - // schema resource ?뚯씪 ?앹꽦 (useSchemaResource=true ???? + // Schema Resource 파일 생성(useSchemaResource=true일 때) if (useSchemaResource) { Files.createDirectories(schemaDir); String inputSchema = """ @@ -818,7 +842,7 @@ public class ToolScaffolder { "properties": { "TODO_FIELD": { "type": "string", - "description": "TODO: ?뚮씪誘명꽣 ?ㅻ챸???낅젰?섏꽭??" + "description": "TODO: 파라미터 설명을 입력하세요." } }, "required": [] @@ -831,19 +855,19 @@ public class ToolScaffolder { "properties": { "status": { "type": "string", - "description": "泥섎━ 寃곌낵 ?곹깭 (SUCCESS / FAILURE)", + "description": "처리 결과 상태 (SUCCESS / FAILURE)", "enum": ["SUCCESS", "FAILURE"] }, "message": { "type": "string", - "description": "泥섎━ 寃곌낵 硫붿떆吏€" + "description": "처리 결과 메시지" } }, "required": ["status"] } """; - Files.writeString(schemaDir.resolve(inputSchemaFileName), inputSchema); - Files.writeString(schemaDir.resolve(outputSchemaFileName), outputSchema); + writeUtf8(schemaDir.resolve(inputSchemaFileName), inputSchema); + writeUtf8(schemaDir.resolve(outputSchemaFileName), outputSchema); log.append("[Input Schema] ").append(schemaDir.resolve(inputSchemaFileName)).append("\n"); log.append("[Output Schema] ").append(schemaDir.resolve(outputSchemaFileName)).append("\n"); } @@ -858,9 +882,9 @@ public class ToolScaffolder { Files.createDirectories(wireMockBodyPath.getParent()); Files.createDirectories(wireMockMappingPath.getParent()); Files.createDirectories(podMockResponsePath.getParent()); - Files.writeString(wireMockBodyPath, mockResponse); - Files.writeString(wireMockMappingPath, wireMockMappingContent(interfaceId, wireMockBodyPath.getFileName().toString())); - Files.writeString(podMockResponsePath, mockResponse); + writeUtf8(wireMockBodyPath, mockResponse); + writeUtf8(wireMockMappingPath, wireMockMappingContent(interfaceId, wireMockBodyPath.getFileName().toString())); + writeUtf8(podMockResponsePath, mockResponse); ensureLocalHttpApiConfiguration(projectRoot, httpApiName, toolName); log.append("[WireMock Response] ").append(wireMockBodyPath).append("\n"); log.append("[WireMock Mapping] ").append(wireMockMappingPath).append("\n"); @@ -868,21 +892,194 @@ public class ToolScaffolder { } else { Path mockResponsePath = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mock-responses", toolName + ".json")); Files.createDirectories(mockResponsePath.getParent()); - Files.writeString(mockResponsePath, mockResponse); + writeUtf8(mockResponsePath, mockResponse); log.append("[Mock Response] ").append(mockResponsePath).append("\n"); } Path generatedTestDir = rootDir.resolve(Paths.get(moduleName, "src/test/java/io/shinhanlife/dap/mcc/biz", group.toLowerCase(), "usecase")); Files.createDirectories(generatedTestDir); Path generatedTestPath = generatedTestDir.resolve(baseName + "UseCaseTest.java"); - Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName)); + writeUtf8(generatedTestPath, useCaseTestContent(bizPackage, baseName)); log.append("[Unit Test] ").append(generatedTestPath).append("\\n"); log.append("[Test Command] .\\gradlew.bat :").append(moduleName.substring(moduleName.lastIndexOf(java.io.File.separator) + 1)).append(":test --tests \"*").append(baseName).append("UseCaseTest\"\\n"); + Files.createDirectories(definitionDir); + Path definitionPath = definitionDir.resolve(toolName + ".yml"); + writeUtf8(definitionPath, toolDefinitionContentV17(toolName, title, description, group, + interfaceId, inputFields, isMutationTool(baseName), definitionOptions)); + log.append("[V17 Tool Definition] ").append(definitionPath).append("\n"); log.append("\n Tip: HTTP Tool은 WireMock 실행 후 생성된 mapping URL로 호출을 확인하세요.\n"); return log.toString(); } + private static String toolDefinitionContent(String toolName, String title, String description, + String categoryKey, String interfaceId, + List inputFields, boolean mutation) { + String safeDescription = description == null || description.isBlank() + ? title + " 기능을 수행한다." : description; + StringBuilder properties = new StringBuilder(); + StringBuilder required = new StringBuilder(); + Set generatedNames = new LinkedHashSet<>(); + for (FieldDefinition field : inputFields == null ? List.of() : inputFields) { + if (field == null || field.name() == null || field.name().isBlank() + || !generatedNames.add(field.name().trim())) { + continue; + } + properties.append(" ").append(field.name()).append(":\n") + .append(" type: ").append(jsonSchemaType(field.type())).append("\n") + .append(" description: ").append(yamlText(field.description())).append("\n"); + if (field.required()) { + required.append(" - ").append(field.name()).append("\n"); + } + } + if (properties.isEmpty()) { + properties.append(" {}\n"); + } + String requiredBlock = required.isEmpty() ? "" : " required:\n" + required; + String legacyLine = interfaceId == null || interfaceId.isBlank() + ? "" : "legacy_interface_id: " + yamlText(interfaceId) + "\n"; + return """ + name: %s + display_name: %s + version: 1.0.0 + category_key: %s + description: + function: %s + when_to_use: 사용자가 이 업무 기능의 실행 또는 조회를 명확히 요청한 경우 사용한다. + when_not_to_use: 입력값이 확인되지 않았거나 다른 업무 기능이 더 적합한 경우에는 사용하지 않는다. + io_limits: 정의된 입력 항목만 허용하며 응답 DTO에 정의된 업무 결과만 반환한다. + display_description: %s + example_queries: + - %s 처리해줘 + - %s 정보를 확인해줘 + - %s 업무 결과를 알려줘 + read_only: %s + destructive: %s + idempotent: %s + parameters_schema: + type: object + properties: + %s%s additionalProperties: false + tags: [%s] + %srequired_env_keys: [] + owner_org: MCP_TOOL + """.formatted(toolName, yamlText(title), categoryKey.toLowerCase(Locale.ROOT), + yamlText(safeDescription), yamlText(title), yamlText(title), yamlText(title), yamlText(title), + !mutation, mutation, !mutation, properties, requiredBlock, + categoryKey.toLowerCase(Locale.ROOT), legacyLine); + } + + private static String toolDefinitionContentV17(String toolName, String title, String description, + String categoryKey, String interfaceId, + List inputFields, boolean mutation, + ToolDefinitionOptions options) { + String function = option(options == null ? null : options.functionDescription(), + option(description, title + " 기능을 수행한다.")); + String whenToUse = option(options == null ? null : options.whenToUse(), + "사용자가 해당 업무 기능의 실행 또는 조회를 명확히 요청한 경우 사용한다."); + String whenNotToUse = option(options == null ? null : options.whenNotToUse(), + "필수 입력값이 확인되지 않았거나 다른 업무 기능이 더 적합한 경우에는 사용하지 않는다."); + String ioLimits = option(options == null ? null : options.ioLimits(), + "정의된 입력 항목만 허용하며 응답 DTO에 정의된 업무 결과만 반환한다."); + String displayDescription = option(options == null ? null : options.displayDescription(), title); + List examples = normalizedList(options == null ? null : options.exampleQueries(), List.of( + title + " 처리해줘", title + " 정보를 확인해줘", title + " 업무 결과를 알려줘")); + List tags = normalizedList(options == null ? null : options.tags(), + List.of(categoryKey.toLowerCase(Locale.ROOT))); + String ownerOrg = option(options == null ? null : options.ownerOrg(), "MCP_TOOL"); + + StringBuilder properties = new StringBuilder(); + StringBuilder required = new StringBuilder(); + Set generatedNames = new LinkedHashSet<>(); + for (FieldDefinition field : inputFields == null ? List.of() : inputFields) { + if (field == null || field.name() == null || field.name().isBlank() + || !generatedNames.add(field.name().trim())) { + continue; + } + properties.append(" ").append(field.name().trim()).append(":\n") + .append(" type: ").append(jsonSchemaType(field.type())).append("\n") + .append(" description: ").append(yamlText(field.description())).append("\n"); + if (field.required()) { + required.append(" - ").append(field.name().trim()).append("\n"); + } + } + if (properties.isEmpty()) { + properties.append(" {}\n"); + } + String requiredBlock = required.isEmpty() ? "" : " required:\n" + required; + String legacyLine = interfaceId == null || interfaceId.isBlank() + ? "" : "legacy_interface_id: " + yamlText(interfaceId) + "\n"; + String exampleBlock = examples.stream().map(value -> " - " + yamlText(value)) + .collect(java.util.stream.Collectors.joining("\n")); + String tagBlock = tags.stream().map(ToolScaffolder::yamlText) + .collect(java.util.stream.Collectors.joining(", ")); + + return """ + name: %s + display_name: %s + version: 1.0.0 + category_key: %s + description: + function: %s + when_to_use: %s + when_not_to_use: %s + io_limits: %s + display_description: %s + example_queries: + %s + read_only: %s + destructive: %s + idempotent: %s + parameters_schema: + type: object + properties: + %s%s additionalProperties: false + tags: [%s] + %srequired_env_keys: [] + owner_org: %s + """.formatted(toolName, yamlText(title), categoryKey.toLowerCase(Locale.ROOT), + yamlText(function), yamlText(whenToUse), yamlText(whenNotToUse), yamlText(ioLimits), + yamlText(displayDescription), exampleBlock, !mutation, mutation, !mutation, + properties, requiredBlock, tagBlock, legacyLine, yamlText(ownerOrg)); + } + + private static String option(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private static List normalizedList(List values, List fallback) { + if (values == null) { + return fallback; + } + List normalized = values.stream() + .filter(java.util.Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isBlank()) + .distinct() + .toList(); + return normalized.isEmpty() ? fallback : normalized; + } + + private static boolean isMutationTool(String baseName) { + String value = baseName.toLowerCase(Locale.ROOT); + return value.matches(".*(create|add|update|delete|remove|send|process|approve|reject|register|issue).*"); + } + + private static String jsonSchemaType(String javaType) { + return switch (javaType == null ? "String" : javaType) { + case "Integer", "Long" -> "integer"; + case "Double", "BigDecimal" -> "number"; + case "Boolean" -> "boolean"; + default -> "string"; + }; + } + + private static String yamlText(String value) { + String safe = value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\r", " ").replace("\n", " "); + return "\"" + safe + "\""; + } + private static String toKebabCase(String pascalCase) { if (pascalCase == null || pascalCase.isEmpty()) return pascalCase; return pascalCase @@ -893,7 +1090,7 @@ public class ToolScaffolder { private static void ensureLocalHttpApiConfiguration(Path projectRoot, String httpApiName, String toolName) throws IOException { Path localConfigPath = projectRoot.resolve("dap-was-lib/src/main/resources/glow/application-glow-local.yml"); Files.createDirectories(localConfigPath.getParent()); - String existing = Files.exists(localConfigPath) ? Files.readString(localConfigPath) : ""; + String existing = Files.exists(localConfigPath) ? Files.readString(localConfigPath, StandardCharsets.UTF_8) : ""; if (java.util.regex.Pattern.compile("(?m)^\\s*-\\s+name:\\s*" + java.util.regex.Pattern.quote(httpApiName) + "\\s*$").matcher(existing).find()) { return; @@ -937,7 +1134,11 @@ public class ToolScaffolder { enabled: true """; } - Files.writeString(localConfigPath, existing); + writeUtf8(localConfigPath, existing); + } + + private static void writeUtf8(Path path, String content) throws IOException { + Files.writeString(path, content, StandardCharsets.UTF_8); } private static String dtoContent(String packageName, String className, List fields, diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSourceUpdater.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSourceUpdater.java index f0384524..cbf45303 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSourceUpdater.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSourceUpdater.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; import java.util.Comparator; /** Updates MCP SDK and project-owned metadata in a generated tool source file. */ @@ -27,10 +28,10 @@ public final class ToolSourceUpdater { throw new IllegalArgumentException("Tool source not found: " + toolName); } - String content = Files.readString(targetFile); + String content = Files.readString(targetFile, StandardCharsets.UTF_8); content = updateMcpTool(content, toolName, description); content = updateToolHint(content, categoryKey, register, requiresApproval); - Files.writeString(targetFile, content); + Files.writeString(targetFile, content, StandardCharsets.UTF_8); } private static Path findToolSource(Path rootDirectory, String toolName) throws IOException { @@ -47,7 +48,7 @@ public final class ToolSourceUpdater { private static boolean containsMcpTool(Path path, String toolName) { try { - return annotationArguments(Files.readString(path), "McpTool", toolName) != null; + return annotationArguments(Files.readString(path, StandardCharsets.UTF_8), "McpTool", toolName) != null; } catch (IOException exception) { throw new IllegalStateException("Failed to read tool source: " + path, exception); } diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunner.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunner.java new file mode 100644 index 00000000..a4492a56 --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunner.java @@ -0,0 +1,78 @@ +package io.shinhanlife.dap.lib.validation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.shinhanlife.dap.lib.metadata.ToolDefinition; +import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** CI/CD에서 모든 Java MCP Tool과 V17 정의 파일의 1:1 대응을 검증합니다. */ +public final class ToolSchemaV17ValidationRunner { + private static final Pattern MCP_TOOL_NAME = Pattern.compile( + "@McpTool\\s*\\(\\s*name\\s*=\\s*\"([^\"]+)\""); + + private ToolSchemaV17ValidationRunner() { + } + + public static void main(String[] args) { + if (args.length != 1) { + throw new IllegalArgumentException("Usage: ToolSchemaV17ValidationRunner "); + } + validate(Path.of(args[0])); + } + + static void validate(Path projectRoot) { + ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + Map definitions = new LinkedHashMap<>(); + Set toolNames = new LinkedHashSet<>(); + try (Stream files = Files.walk(projectRoot)) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + String normalized = file.toString().replace('\\', '/'); + if (normalized.contains("/build/") || normalized.contains("/.gradle/") + || normalized.contains("/tmp_") || normalized.contains("/org/")) { + continue; + } + if (normalized.endsWith(".java") && normalized.contains("/dap-was-") + && !normalized.contains("/dap-was-lib/")) { + Matcher matcher = MCP_TOOL_NAME.matcher(Files.readString(file, StandardCharsets.UTF_8)); + while (matcher.find()) { + toolNames.add(matcher.group(1)); + } + } + if (normalized.contains("/src/main/resources/tool-definitions/") + && (normalized.endsWith(".yml") || normalized.endsWith(".yaml"))) { + ToolDefinition definition = yamlMapper.readValue(file.toFile(), ToolDefinition.class); + ToolDefinitionValidator.validate(definition, file.toString()); + Path previous = definitions.putIfAbsent(definition.name(), file); + if (previous != null) { + throw new IllegalStateException("Duplicate V17 Tool definition: " + definition.name() + + " [" + previous + ", " + file + "]"); + } + } + } + } catch (IOException exception) { + throw new IllegalStateException("Failed to scan Tool schema V17 files", exception); + } + Set missing = new LinkedHashSet<>(toolNames); + missing.removeAll(definitions.keySet()); + if (!missing.isEmpty()) { + throw new IllegalStateException("Missing V17 Tool definitions: " + missing); + } + Set orphan = new LinkedHashSet<>(definitions.keySet()); + orphan.removeAll(toolNames); + if (!orphan.isEmpty()) { + throw new IllegalStateException("V17 definitions without matching @McpTool: " + orphan); + } + System.out.println("Tool schema V17 validation passed: " + toolNames.size() + " tools"); + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java index 44e9f7d9..3e228913 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java @@ -44,9 +44,19 @@ public class ToolMetadata { private String displayName; // 사람이 읽는 라벨 (1-128자) private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool) private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능) + private String functionDescription; + private String whenToUse; + private String whenNotToUse; + private String ioLimits; + private String displayDescription; + private List exampleQueries; + private List tags; + private String ownerOrg; + private List requiredEnvKeys; // 2. 파라미터 스키마 (JSON Schema 형태의 Map) private Map parametersSchema; + private Map outputSchema; // 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨) private Map actionPrompts; diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepositoryTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepositoryTest.java new file mode 100644 index 00000000..f423e1bb --- /dev/null +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepositoryTest.java @@ -0,0 +1,73 @@ +package io.shinhanlife.dap.lib.metadata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.io.DefaultResourceLoader; + +class ToolDefinitionRepositoryTest { + + @Test + void springCanCreateRepositoryWithoutDefaultConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(ToolDefinitionRepository.class); + context.refresh(); + + assertTrue(context.getBean(ToolDefinitionRepository.class) + .findByName("cmm_claim_search").isPresent()); + } + } + + @Test + void loadsAndCachesAValidV17DefinitionByToolName() { + ToolDefinitionRepository repository = new ToolDefinitionRepository( + new ObjectMapper(new YAMLFactory()), new DefaultResourceLoader(), + "classpath*:tool-definitions/**/*.yml"); + + ToolDefinition definition = repository.findByName("cmm_claim_search").orElseThrow(); + + assertEquals("보험금 청구 상태 조회", definition.displayName()); + assertEquals("cmm", definition.categoryKey()); + assertEquals(3, definition.exampleQueries().size()); + assertEquals(false, definition.parametersSchema().get("additionalProperties")); + } + + @Test + void rejectsDefinitionWithFewerThanThreeExampleQueries() { + ToolDefinition invalid = validDefinition().withExampleQueries(java.util.List.of("청구 상태 알려줘")); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> ToolDefinitionValidator.validate(invalid, "memory:invalid")); + + assertTrue(error.getMessage().contains("example_queries")); + } + + @Test + void rejectsNonStandardToolName() { + ToolDefinition invalid = validDefinition().withName("cmm_claim.Search"); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> ToolDefinitionValidator.validate(invalid, "memory:invalid")); + + assertTrue(error.getMessage().contains("name")); + } + + private ToolDefinition validDefinition() { + return new ToolDefinition( + "cmm_claim_search", "보험금 청구 상태 조회", "1.0.0", "cmm", + new ToolDescription("청구 상태를 조회한다.", "상태 확인 시 사용한다.", + "청구 접수 시 사용하지 않는다.", "청구번호가 필요하다."), + "보험금 청구 상태를 조회합니다.", + java.util.List.of("청구 상태 알려줘", "심사 결과 조회해줘", "계약번호로 청구를 찾아줘"), + true, false, true, + java.util.Map.of("type", "object", "properties", java.util.Map.of(), + "additionalProperties", false), + null, + java.util.List.of("보험금"), null, java.util.List.of(), "MCP_TOOL"); + } +} diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java index 27aceba1..2381031a 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java @@ -91,18 +91,25 @@ class JsonSchemaGeneratorTest { private static class ValidatedRequest { @McpToolParam(description = "recipient phone number", required = true) + @io.swagger.v3.oas.annotations.media.Schema(pattern = "^01[0-9]{8,9}$") private String phoneNumber; @McpToolParam(description = "issue amount", required = true) + @io.swagger.v3.oas.annotations.media.Schema(minimum = "1") private Long amount; @McpToolParam(description = "approval result") + @io.swagger.v3.oas.annotations.media.Schema( + requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED, + allowableValues = {"APPROVE", "REJECT"}) private String approvalStatus; @McpToolParam(description = "page size") + @io.swagger.v3.oas.annotations.media.Schema(maximum = "50", defaultValue = "20") private Integer pageSize; @McpToolParam(description = "reference") + @io.swagger.v3.oas.annotations.media.Schema(minLength = 1, maxLength = 30) private String reference; } @@ -115,6 +122,9 @@ class JsonSchemaGeneratorTest { } private static class NestedChild { + @io.swagger.v3.oas.annotations.media.Schema( + requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED, + pattern = "^\\d{8}$") private String businessDate; } } diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java index 8f7073ad..6a48c94f 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java @@ -1,17 +1,109 @@ package io.shinhanlife.dap.lib.util; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.shinhanlife.dap.lib.metadata.ToolDefinition; +import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; class ToolScaffolderTest { + @Test + void generatesEveryToolSourceAsUtf8WithoutBrokenKoreanOrBom() throws Exception { + String moduleName = root.resolve("dap-was-korean").toString(); + + ToolScaffolder.scaffold("analysis data query", "CLYMCI00001", "분석 데이터 조회", + "분석 데이터를 조회하기 위한 도구로 다양한 분석 결과를 제공합니다.", + "cmm", "MCI", moduleName, "테스터", "2026.08.12", + false, null, null, null, + List.of(new ToolScaffolder.FieldDefinition("query", "String", "조회 조건", "계약 분석", true)), + List.of(new ToolScaffolder.FieldDefinition("analysisResult", "String", "분석 결과", "정상", false))); + + Path sourceRoot = root.resolve("dap-was-korean/src/main/java/io/shinhanlife/dap/mcc"); + Path useCase = sourceRoot.resolve("biz/cmm/usecase/AnalysisDataQueryUseCase.java"); + Path implementation = sourceRoot.resolve("biz/cmm/usecase/impl/AnalysisDataQueryUseCaseImpl.java"); + + String useCaseSource = Files.readString(useCase, StandardCharsets.UTF_8); + String implementationSource = Files.readString(implementation, StandardCharsets.UTF_8); + assertTrue(useCaseSource.contains("title = \"분석 데이터 조회\""), useCaseSource); + assertTrue(useCaseSource.contains("description = \"분석 데이터를 조회하기 위한 도구로 다양한 분석 결과를 제공합니다.\""), useCaseSource); + assertTrue(useCaseSource.contains("AX HUB 시스템 처리 클래스"), useCaseSource); + assertTrue(useCaseSource.contains("개정이력"), useCaseSource); + assertTrue(useCaseSource.contains("최초생성"), useCaseSource); + assertTrue(implementationSource.contains("요청 수신"), implementationSource); + assertTrue(implementationSource.contains("MapStruct를 이용한 자동 매핑"), implementationSource); + assertTrue(implementationSource.contains("연동 중 오류 발생"), implementationSource); + + try (var files = Files.walk(root.resolve("dap-was-korean"))) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + byte[] bytes = Files.readAllBytes(file); + assertFalse(bytes.length >= 3 + && (bytes[0] & 0xff) == 0xef + && (bytes[1] & 0xff) == 0xbb + && (bytes[2] & 0xff) == 0xbf, + "UTF-8 BOM must not be generated: " + file); + String content = Files.readString(file, StandardCharsets.UTF_8); + assertFalse(content.contains("�"), "Invalid replacement character: " + file); + } + } + } + + @Test + void generatesV17ToolDefinitionTogetherWithToolSources() throws Exception { + String moduleName = root.resolve("dap-was-v17-definition").toString(); + + ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", + "사번으로 재직 중인 직원을 조회한다.", "smp", "HTTP", moduleName, + "tester", "2026.08.12", false, null, null, null, + List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "09860000", true)), + List.of(), "employee"); + + Path definition = root.resolve("dap-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); + String yaml = Files.readString(definition); + + assertTrue(yaml.contains("name: smp_employee_search"), yaml); + assertTrue(yaml.contains("when_to_use:"), yaml); + assertTrue(yaml.contains("example_queries:"), yaml); + assertTrue(yaml.contains("additionalProperties: false"), yaml); + assertTrue(yaml.contains("owner_org: \"MCP_TOOL\""), yaml); + ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(yaml, ToolDefinition.class); + ToolDefinitionValidator.validate(parsed, definition.toString()); + } + + @Test + void appliesV17MetadataEnteredByScaffoldUser() throws Exception { + String moduleName = root.resolve("dap-was-v17-options").toString(); + ToolScaffolder.ToolDefinitionOptions options = new ToolScaffolder.ToolDefinitionOptions( + "사번으로 직원을 조회한다.", + "직원 정보 조회 요청에 사용한다.", + "사번이 없으면 사용하지 않는다.", + "최대 1건만 반환한다.", + "직원 기본 정보 조회", + List.of("사번 10001을 조회해줘", "직원 10001 소속을 알려줘", "10001 직원을 찾아줘"), + List.of("employee", "search"), "HR_TEAM"); + + ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", "직원을 조회한다.", + "smp", "HTTP", moduleName, "tester", "2026.08.12", false, null, null, null, + List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "10001", true)), + List.of(), "employee", options); + + Path definition = root.resolve("dap-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); + ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(Files.readString(definition), ToolDefinition.class); + assertEquals("HR_TEAM", parsed.ownerOrg()); + assertEquals("10001 직원을 찾아줘", parsed.exampleQueries().get(2)); + assertEquals(2, parsed.tags().size()); + ToolDefinitionValidator.validate(parsed, definition.toString()); + } + @TempDir Path root; diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java index cd29bc3a..85de539b 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java @@ -35,6 +35,8 @@ class ToolManifestServiceTest { assertTrue(item.annotations().readOnlyHint()); assertEquals("1.2.0", item.meta().version()); assertEquals(3000, item.meta().timeoutMillis()); + assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"), + item.meta().exampleQueries()); } @Test @@ -86,6 +88,9 @@ class ToolManifestServiceTest { .podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080") .displayName("계약 조회") .description("계약번호로 계약 정보를 조회합니다.") + .exampleQueries(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘")) + .tags(List.of("계약")) + .ownerOrg("MCP_TOOL") .parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")), "required", List.of("contractNo"), "additionalProperties", false)) .semver(version) diff --git a/dap-was-lib/src/test/resources/tool-definitions/cmm/cmm_claim_search.yml b/dap-was-lib/src/test/resources/tool-definitions/cmm/cmm_claim_search.yml new file mode 100644 index 00000000..e3193c63 --- /dev/null +++ b/dap-was-lib/src/test/resources/tool-definitions/cmm/cmm_claim_search.yml @@ -0,0 +1,30 @@ +name: cmm_claim_search +display_name: 보험금 청구 상태 조회 +version: 1.0.0 +category_key: cmm +description: + function: 청구번호 또는 계약번호로 보험금 청구 상태를 조회한다. + when_to_use: 사용자가 보험금 청구 진행 상태나 심사 결과를 확인하려는 경우 사용한다. + when_not_to_use: 보험금 청구를 새로 접수하거나 기존 청구를 변경하려는 경우에는 사용하지 않는다. + io_limits: 청구번호 또는 계약번호 중 하나 이상이 필요하며 조회 결과만 반환한다. +display_description: 보험금 청구 상태와 심사 결과를 조회합니다. +example_queries: + - 내 보험금 청구가 어디까지 진행됐는지 알려줘 + - 계약번호로 최근 청구 상태를 확인해줘 + - 청구 심사 결과가 나왔는지 조회해줘 +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + claimNo: + type: string + description: 조회할 보험금 청구번호 + required: + - claimNo + additionalProperties: false +tags: + - 보험금 + - 청구조회 +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MetaCommonCodeUseCase.java b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MetaCommonCodeUseCase.java index 2dc43e5d..9e9823f6 100644 --- a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MetaCommonCodeUseCase.java +++ b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MetaCommonCodeUseCase.java @@ -20,7 +20,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest; * */ public interface MetaCommonCodeUseCase { - @McpTool(name = "cmm_commonCode_lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true)) + @McpTool(name = "cmm_comcode_lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true)) @ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001") Object execute(MetaCommonCodeRequest req); } diff --git a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/ins/usecase/InsuranceClaimProcessorUseCase.java b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/ins/usecase/InsuranceClaimProcessorUseCase.java index ec3faaa9..e061235d 100644 --- a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/ins/usecase/InsuranceClaimProcessorUseCase.java +++ b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/ins/usecase/InsuranceClaimProcessorUseCase.java @@ -8,14 +8,14 @@ import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse; /** * @package io.shinhanlife.dap.mcc.biz.ins.usecase * @className InsuranceClaimProcessorUseCase - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author Admin * @create 2026.08.11 *
- * ---------- 媛쒖젙?대젰 ----------
- * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
  * ---------- -------- ---------------------------
- * 2026.08.11  Admin    理쒖큹?앹꽦
+ * 2026.08.11  Admin    최초생성
  *
  * 
*/ diff --git a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/smp/usecase/ExchangeRateToolUseCase.java b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/smp/usecase/ExchangeRateToolUseCase.java index aa4f3cb4..67861184 100644 --- a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/smp/usecase/ExchangeRateToolUseCase.java +++ b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/biz/smp/usecase/ExchangeRateToolUseCase.java @@ -9,7 +9,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest; import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse; public interface ExchangeRateToolUseCase { - @McpTool(name = "smp_exchangeRate_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)") + @McpTool(name = "smp_exchange_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)") @ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001") ExchangeRateResponse execute(ExchangeRateRequest req); } diff --git a/dap-was-oth/src/main/resources/application-dev.yml b/dap-was-oth/src/main/resources/application-dev.yml index 658693ab..b3e64636 100644 --- a/dap-was-oth/src/main/resources/application-dev.yml +++ b/dap-was-oth/src/main/resources/application-dev.yml @@ -1,4 +1,4 @@ -# OCI ?대씪?곕뱶 ?섍꼍 ?꾩슜 ?ㅼ젙 +# OCI 클라우드 개발 환경 전용 설정 server: port: ${PORT:8084} diff --git a/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_comcode_lookup.yml b/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_comcode_lookup.yml new file mode 100644 index 00000000..cac9b1d3 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_comcode_lookup.yml @@ -0,0 +1,25 @@ +name: cmm_comcode_lookup +display_name: 메타 공통코드 조회 +version: 1.0.0 +category_key: cmm +description: + function: 통합코드 그룹과 코드명 조건으로 메타 공통코드 목록을 조회한다. + when_to_use: 업무 코드의 값과 표시명을 확인하거나 유효한 코드 목록이 필요한 경우 사용한다. + when_not_to_use: 공통코드를 신규 등록하거나 변경 또는 삭제하려는 경우에는 사용하지 않는다. + io_limits: 검색 조건에 맞는 코드와 코드명만 반환하며 코드 데이터는 변경하지 않는다. +display_description: 메타 시스템의 공통코드 목록을 조회합니다. +example_queries: ["사용 상태 코드 목록을 알려줘", "고객 구분 공통코드를 찾아줘", "사용 중인 통합코드를 조회해줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + groupCode: {type: string, description: 조회할 통합코드 그룹 ID} + codeName: {type: string, description: 코드명 검색 키워드} + useYn: {type: string, description: 사용 여부 Y 또는 N, enum: [Y, N]} + additionalProperties: false +tags: [메타, 공통코드] +legacy_interface_id: CLCNNB00001 +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_meta_table.yml b/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_meta_table.yml new file mode 100644 index 00000000..d9fe5b63 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_meta_table.yml @@ -0,0 +1,24 @@ +name: cmm_meta_table +display_name: 메타 테이블 조회 +version: 1.0.0 +category_key: cmm +description: + function: 물리명, 논리명 또는 소유자 조건으로 메타 테이블 정보를 조회한다. + when_to_use: 사용자가 업무 데이터의 테이블명이나 소유 스키마를 확인하려는 경우 사용한다. + when_not_to_use: 테이블을 생성하거나 구조를 변경하려는 경우에는 사용하지 않는다. + io_limits: 메타에 등록된 테이블 설명 정보만 반환하며 실제 테이블 데이터는 조회하지 않는다. +display_description: 메타 시스템에 등록된 테이블 정보를 조회합니다. +example_queries: ["고객 기본 테이블을 찾아줘", "계약 관련 테이블 목록을 보여줘", "특정 스키마의 테이블을 조회해줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + tableName: {type: string, description: 테이블 물리명 검색어} + tableLogicalName: {type: string, description: 테이블 논리명 검색어} + owner: {type: string, description: 테이블 소유 스키마명} + additionalProperties: false +tags: [메타, 테이블] +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_template_url.yml b/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_template_url.yml new file mode 100644 index 00000000..5b9afc71 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/cmm/cmm_template_url.yml @@ -0,0 +1,23 @@ +name: cmm_template_url +display_name: 업무 템플릿 다운로드 URL 조회 +version: 1.0.0 +category_key: cmm +description: + function: 요청한 업무 템플릿 파일을 내려받을 수 있는 URL을 반환한다. + when_to_use: 사용자가 엑셀이나 워드 업무 양식의 다운로드 위치를 요청한 경우 사용한다. + when_not_to_use: 템플릿 내용을 작성하거나 업로드 또는 변경하려는 경우에는 사용하지 않는다. + io_limits: 등록된 템플릿 ID에 대한 다운로드 URL만 반환하며 파일 자체는 반환하지 않는다. +display_description: 업무 템플릿을 다운로드할 수 있는 URL을 제공합니다. +example_queries: ["청구 양식 다운로드 링크를 알려줘", "업무용 엑셀 템플릿을 받고 싶어", "등록된 문서 양식 위치를 찾아줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + templateId: {type: string, description: 다운로드할 템플릿 식별자} + required: [templateId] + additionalProperties: false +tags: [템플릿, 다운로드] +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/ins/ins_insurance_processor.yml b/dap-was-oth/src/main/resources/tool-definitions/ins/ins_insurance_processor.yml new file mode 100644 index 00000000..2a746341 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/ins/ins_insurance_processor.yml @@ -0,0 +1,26 @@ +name: ins_insurance_processor +display_name: 보험금 청구 처리 +version: 1.0.0 +category_key: ins +description: + function: 보험금 청구번호, 청구금액과 청구일을 받아 청구 처리 요청을 수행한다. + when_to_use: 사용자가 확인된 보험금 청구 정보를 실제 처리계에 접수하려는 경우 사용한다. + when_not_to_use: 청구 상태만 조회하거나 필수 정보가 확인되지 않은 경우에는 사용하지 않는다. + io_limits: 실행 시 업무 상태가 변경될 수 있으므로 호출 전에 입력값과 사용자 의사를 확인해야 한다. +display_description: 확인된 보험금 청구 요청을 처리계에 전달합니다. +example_queries: ["확인한 내용으로 보험금 청구를 접수해줘", "이 청구번호의 보험금 처리를 진행해줘", "오늘 날짜로 보험금 청구 요청을 보내줘"] +read_only: false +destructive: true +idempotent: false +parameters_schema: + type: object + properties: + claimNumber: {type: string, description: 처리할 보험금 청구번호} + claimAmount: {type: number, description: 처리할 보험금 청구금액} + claimDate: {type: string, description: 청구일자 YYYYMMDD, pattern: "^[0-9]{8}$"} + required: [claimNumber, claimAmount, claimDate] + additionalProperties: false +tags: [보험금, 청구처리] +legacy_interface_id: CLAIM0000001 +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/oth/oth_onnba3011_call.yml b/dap-was-oth/src/main/resources/tool-definitions/oth/oth_onnba3011_call.yml new file mode 100644 index 00000000..77e1a91d --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/oth/oth_onnba3011_call.yml @@ -0,0 +1,35 @@ +name: oth_onnba3011_call +display_name: ONNBA3011 보험 업무 조회 +version: 1.0.0 +category_key: oth +description: + function: ONNBA3011 입력정보를 MCI 전문으로 변환해 보험 업무 결과를 조회한다. + when_to_use: 사용자가 ONNBA3011 인터페이스 기준의 보험 계약 또는 지급 정보를 조회하려는 경우 사용한다. + when_not_to_use: 인터페이스 입력값을 확인할 수 없거나 보험 정보를 변경하려는 경우에는 사용하지 않는다. + io_limits: CLCNNB00001 인터페이스 규격에 맞는 값만 전달하며 조회 결과를 업무 응답으로 변환한다. +display_description: ONNBA3011 업무 정보를 MCI로 조회합니다. +example_queries: ["고객의 보험 업무 정보를 조회해줘", "ONNBA3011 기준으로 계약 정보를 확인해줘", "입력한 고객번호의 보험 결과를 알려줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + dalScCd: {type: string, description: 거래 구분 코드} + cstSucoRltyCd: {type: string, description: 고객 성공 관계 코드} + csNo: {type: string, description: 고객 번호} + rdreNo: {type: string, description: 설계사 번호} + unfcPvsCalReqYn: {type: string, description: 미확정 지급 계산 요청 여부} + kcisPymmTnnrRequest: {type: string, description: KCIS 납입 기간 요청값} + lmovYn: {type: string, description: 계약 이동 여부} + genPsthApvTrgtYn: {type: string, description: 일반 사후 승인 대상 여부} + ircoLmovEcpbTrgtYn: {type: string, description: 계약 이동 예외 대상 여부} + digCalYn: {type: string, description: 디지털 계산 여부} + prbuIciDigCalYn: {type: string, description: 상품별 디지털 계산 여부} + unfcPrbuIrcoAddu: {type: object, description: 미확정 상품 추가 정보, additionalProperties: true} + sucoIspaBasDto: {type: object, description: 성공 심사 기본 정보, additionalProperties: true} + additionalProperties: false +tags: [보험, MCI] +legacy_interface_id: CLCNNB00001 +required_env_keys: [GLOW_COMMUNICATION_MCI_HOST, GLOW_COMMUNICATION_MCI_PORT] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/smp/smp_exchange_inquiry.yml b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_exchange_inquiry.yml new file mode 100644 index 00000000..097aa3e6 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_exchange_inquiry.yml @@ -0,0 +1,22 @@ +name: smp_exchange_inquiry +display_name: 실시간 환율 조회 +version: 1.0.0 +category_key: smp +description: + function: 통화코드를 기준으로 현재 환율 정보를 조회한다. + when_to_use: 사용자가 USD, EUR, JPY 등 특정 통화의 환율을 확인하려는 경우 사용한다. + when_not_to_use: 환전 거래를 실행하거나 과거 환율 통계를 분석하려는 경우에는 사용하지 않는다. + io_limits: 지원되는 통화코드의 조회 시점 환율만 반환하며 실제 환전 기능은 제공하지 않는다. +display_description: 지정한 통화의 현재 환율을 조회합니다. +example_queries: ["오늘 달러 환율을 알려줘", "엔화 환율이 얼마인지 조회해줘", "유로 환율을 확인해줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + currencyCode: {type: string, description: 조회할 ISO 통화코드, pattern: "^[A-Z]{3}$"} + additionalProperties: false +tags: [환율, 금융] +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/smp/smp_quote_daily.yml b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_quote_daily.yml new file mode 100644 index 00000000..1ee08ff1 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_quote_daily.yml @@ -0,0 +1,22 @@ +name: smp_quote_daily +display_name: 오늘의 명언 조회 +version: 1.0.0 +category_key: smp +description: + function: 선택한 카테고리에 맞는 오늘의 명언 한 건을 조회한다. + when_to_use: 사용자가 명언이나 짧은 동기부여 문구를 요청한 경우 사용한다. + when_not_to_use: 업무 데이터 조회나 사실 검증이 필요한 질문에는 사용하지 않는다. + io_limits: 등록된 명언 데이터 중 한 건만 반환하며 출처 정보가 없을 수 있다. +display_description: 카테고리에 맞는 오늘의 명언을 제공합니다. +example_queries: ["오늘 힘이 되는 말을 알려줘", "업무 시작 전에 명언 하나 보여줘", "성공에 관한 짧은 문구를 추천해줘"] +read_only: true +destructive: false +idempotent: false +parameters_schema: + type: object + properties: + category: {type: string, description: 조회할 명언 카테고리} + additionalProperties: false +tags: [명언, 콘텐츠] +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/smp/smp_team_list.yml b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_team_list.yml new file mode 100644 index 00000000..51c53203 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_team_list.yml @@ -0,0 +1,22 @@ +name: smp_team_list +display_name: MCP·TOOL 파트 구성원 조회 +version: 1.0.0 +category_key: smp +description: + function: 신한라이프 AX 추진팀과 MCP·TOOL 파트 구성원 및 직급을 조회한다. + when_to_use: 사용자가 프로젝트 담당자나 팀별 구성원 정보를 묻는 경우 사용한다. + when_not_to_use: 인사 개인정보나 연락처 또는 조직 변경 이력을 요청하는 경우에는 사용하지 않는다. + io_limits: 사전에 등록된 구성원의 이름과 직급만 반환하며 민감한 인사정보는 포함하지 않는다. +display_description: 신한라이프 MCP·TOOL 파트 담당자와 구성원을 조회합니다. +example_queries: ["MCP 팀 담당자를 알려줘", "TOOL 파트 구성원이 누구인지 보여줘", "AX 추진팀 담당자를 찾아줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + teamName: {type: string, description: 조회할 팀 이름 또는 전체} + additionalProperties: false +tags: [조직, 담당자] +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/smp/smp_weather_inquiry.yml b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_weather_inquiry.yml new file mode 100644 index 00000000..c2d6f6fd --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/smp/smp_weather_inquiry.yml @@ -0,0 +1,23 @@ +name: smp_weather_inquiry +display_name: 도시 날씨 조회 +version: 1.0.0 +category_key: smp +description: + function: 도시명을 기준으로 현재 날씨, 온도와 풍속을 조회한다. + when_to_use: 사용자가 특정 도시의 현재 기상 정보를 요청한 경우 사용한다. + when_not_to_use: 장기 예보나 기상 특보 또는 공식 재난정보가 필요한 경우에는 사용하지 않는다. + io_limits: 입력한 도시의 현재 관측 기반 샘플 정보만 반환한다. +display_description: 지정한 도시의 현재 날씨 정보를 조회합니다. +example_queries: ["서울 날씨를 알려줘", "부산의 현재 온도를 조회해줘", "제주도 바람이 얼마나 부는지 알려줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + city: {type: string, description: 날씨를 조회할 도시명} + required: [city] + additionalProperties: false +tags: [날씨, 조회] +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/sol/sol_request_detail.yml b/dap-was-oth/src/main/resources/tool-definitions/sol/sol_request_detail.yml new file mode 100644 index 00000000..005f09a6 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/sol/sol_request_detail.yml @@ -0,0 +1,24 @@ +name: sol_request_detail +display_name: SOL 의뢰서 상세 조회 +version: 1.0.0 +category_key: sol +description: + function: SOL 의뢰서 식별자를 기준으로 의뢰서 상세 내용을 조회한다. + when_to_use: 사용자가 특정 SOL 의뢰서의 상세 내용과 진행 정보를 확인하려는 경우 사용한다. + when_not_to_use: 의뢰서 목록만 찾거나 의뢰서를 변경 또는 처리하려는 경우에는 사용하지 않는다. + io_limits: 정확한 의뢰서 ID가 필요하며 등록된 상세 정보만 반환한다. +display_description: SOL 의뢰서 한 건의 상세 정보를 조회합니다. +example_queries: ["이 SOL 의뢰서 상세를 보여줘", "의뢰서 ID로 처리 내용을 확인해줘", "선택한 의뢰서의 상세 정보를 알려줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + srId: {type: string, description: 상세 조회할 SOL 의뢰서 ID} + required: [srId] + additionalProperties: false +tags: [SOL, 의뢰서] +legacy_interface_id: SOLG00000002 +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-oth/src/main/resources/tool-definitions/sol/sol_request_list.yml b/dap-was-oth/src/main/resources/tool-definitions/sol/sol_request_list.yml new file mode 100644 index 00000000..8c2fa3b9 --- /dev/null +++ b/dap-was-oth/src/main/resources/tool-definitions/sol/sol_request_list.yml @@ -0,0 +1,25 @@ +name: sol_request_list +display_name: SOL 의뢰서 목록 조회 +version: 1.0.0 +category_key: sol +description: + function: 진행상태, 조회기간과 조회대상 조건으로 SOL 의뢰서 목록을 조회한다. + when_to_use: 사용자가 자신의 SOL 의뢰서나 상태별 의뢰서 목록을 확인하려는 경우 사용한다. + when_not_to_use: 특정 의뢰서의 상세 내용만 보거나 의뢰서를 변경하려는 경우에는 사용하지 않는다. + io_limits: 입력 조건에 해당하는 의뢰서 요약 목록만 반환한다. +display_description: 조건에 맞는 SOL 의뢰서 목록을 조회합니다. +example_queries: ["진행 중인 SOL 의뢰서를 보여줘", "최근 한 달간 내 의뢰서를 조회해줘", "완료된 의뢰서 목록을 알려줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + status: {type: string, description: 조회할 의뢰서 진행상태} + period: {type: string, description: 조회할 기간 조건} + target: {type: string, description: 나의 업무 또는 전체 조회대상} + additionalProperties: false +tags: [SOL, 의뢰서] +legacy_interface_id: SOLG00000001 +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-sms/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MemoListRetrieverUseCase.java b/dap-was-sms/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MemoListRetrieverUseCase.java index 13cb3645..47a74a93 100644 --- a/dap-was-sms/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MemoListRetrieverUseCase.java +++ b/dap-was-sms/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/MemoListRetrieverUseCase.java @@ -8,14 +8,14 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverResponse; /** * @package io.shinhanlife.dap.mcc.biz.cmm.usecase * @className MemoListRetrieverUseCase - * @description AX HUB ?쒖뒪??泥섎━ ?대옒?? + * @description AX HUB 시스템 처리 클래스 * @author Admin * @create 2026.08.11 *
- * ---------- 媛쒖젙?대젰 ----------
- * ?섏젙??     ?섏젙??   ?섏젙?댁슜
+ * ---------- 개정이력 ----------
+ * 수정일       수정자     수정내용
  * ---------- -------- ---------------------------
- * 2026.08.11  Admin    理쒖큹?앹꽦
+ * 2026.08.11  Admin    최초생성
  *
  * 
*/ diff --git a/dap-was-sms/src/main/resources/application-dev.yml b/dap-was-sms/src/main/resources/application-dev.yml index dfee070e..6aed956e 100644 --- a/dap-was-sms/src/main/resources/application-dev.yml +++ b/dap-was-sms/src/main/resources/application-dev.yml @@ -1,4 +1,4 @@ -# OCI ?대씪?곕뱶 ?섍꼍 ?꾩슜 ?ㅼ젙 +# OCI 클라우드 개발 환경 전용 설정 server: port: ${PORT:8082} diff --git a/dap-was-sms/src/main/resources/tool-definitions/cmm/cmm_claim_search.yml b/dap-was-sms/src/main/resources/tool-definitions/cmm/cmm_claim_search.yml new file mode 100644 index 00000000..9af8b88d --- /dev/null +++ b/dap-was-sms/src/main/resources/tool-definitions/cmm/cmm_claim_search.yml @@ -0,0 +1,24 @@ +name: cmm_claim_search +display_name: 보험금 청구 상태 조회 +version: 1.0.0 +category_key: cmm +description: + function: 청구번호 또는 계약번호로 보험금 청구 상태와 심사 결과를 조회한다. + when_to_use: 사용자가 기존 보험금 청구의 진행 상태나 지급 결과를 확인하려는 경우 사용한다. + when_not_to_use: 보험금 청구를 새로 접수하거나 기존 청구 내용을 변경하려는 경우에는 사용하지 않는다. + io_limits: 청구번호 또는 계약번호 중 하나 이상이 필요하며 조회 결과만 반환한다. +display_description: 보험금 청구 상태와 심사 결과를 조회합니다. +example_queries: ["내 보험금 청구가 어디까지 진행됐는지 알려줘", "계약번호로 최근 청구 상태를 확인해줘", "청구 심사 결과가 나왔는지 조회해줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + claimNo: {type: string, description: 조회할 보험금 청구번호} + contractNo: {type: string, description: 조회할 보험계약 번호} + additionalProperties: false +tags: [보험금, 청구조회] +legacy_interface_id: CLCNNB00001 +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/dap-was-sms/src/main/resources/tool-definitions/cmm/cmm_memo_retriever.yml b/dap-was-sms/src/main/resources/tool-definitions/cmm/cmm_memo_retriever.yml new file mode 100644 index 00000000..7c871a60 --- /dev/null +++ b/dap-was-sms/src/main/resources/tool-definitions/cmm/cmm_memo_retriever.yml @@ -0,0 +1,24 @@ +name: cmm_memo_retriever +display_name: 의뢰서 목록 조회 +version: 1.0.0 +category_key: cmm +description: + function: 상태와 검색어 조건으로 의뢰서 목록을 조회한다. + when_to_use: 사용자가 등록된 의뢰서의 목록이나 처리 현황을 찾으려는 경우 사용한다. + when_not_to_use: 의뢰서를 신규 등록하거나 내용을 변경하려는 경우에는 사용하지 않는다. + io_limits: 선택 조건에 해당하는 의뢰서 요약 목록만 반환한다. +display_description: 조건에 맞는 의뢰서 목록을 조회합니다. +example_queries: ["처리 중인 의뢰서를 보여줘", "지난번 계약 관련 의뢰서를 찾아줘", "완료된 의뢰서 목록을 조회해줘"] +read_only: true +destructive: false +idempotent: true +parameters_schema: + type: object + properties: + memoStatus: {type: string, description: 조회할 의뢰서 처리 상태} + searchKeyword: {type: string, description: 의뢰서 제목 또는 내용 검색어} + additionalProperties: false +tags: [의뢰서, 목록조회] +legacy_interface_id: MEMO0000001 +required_env_keys: [] +owner_org: MCP_TOOL diff --git a/docs/superpowers/plans/2026-08-12-scaffold-ai-v17-metadata.md b/docs/superpowers/plans/2026-08-12-scaffold-ai-v17-metadata.md new file mode 100644 index 00000000..2315a218 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-scaffold-ai-v17-metadata.md @@ -0,0 +1,58 @@ +# Scaffold AI V17 Metadata Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AI Tool 초안 생성 시 Tool Schema V17 Metadata 전체를 생성하고 Scaffold 폼에 자동 입력한다. + +**Architecture:** 기존 `/tool-draft` 응답 DTO를 확장하고 서버에서 값을 정규화한다. 화면은 서버 응답의 필드를 기존 V17 폼 요소에 직접 매핑하며, 실제 Scaffold 생성은 기존 `ToolDefinitionOptions` 경로를 그대로 사용한다. + +**Tech Stack:** Java 21, Spring Boot MVC, Jackson, JUnit 5, MockMvc, HTML/JavaScript + +## Global Constraints + +- 기존 Tool 생성 경로와 Legacy 연동 정보 입력 정책을 유지한다. +- Tool Schema V17 필드명은 기존 `ToolDefinitionOptions`와 동일하게 유지한다. +- 기존 작업 트리 변경사항을 되돌리거나 포함 범위 밖에서 수정하지 않는다. + +--- + +### Task 1: AI Tool Draft 서버 계약 확장 + +**Files:** +- Modify: `dap-gateway/src/test/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingControllerToolDraftTest.java` +- Modify: `dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java` + +**Interfaces:** +- Consumes: AI가 반환한 JSON Tool 초안 +- Produces: V17 Metadata가 포함된 `/api/v1/scaffold/tool-draft` JSON 응답 + +- [ ] Mock AI 응답과 API assertion에 V17 필드를 추가한다. +- [ ] 테스트를 실행해 현재 응답에서 V17 필드가 누락되어 실패하는지 확인한다. +- [ ] `ToolDraft`와 프롬프트 및 정규화 로직을 확장한다. +- [ ] 테스트가 통과하는지 확인한다. + +### Task 2: Scaffold 화면 자동 매핑 + +**Files:** +- Modify: `dap-gateway/src/main/resources/static/admin/scaffold.html` + +**Interfaces:** +- Consumes: Task 1의 V17 Metadata JSON 필드 +- Produces: 같은 이름을 가진 V17 폼 입력값 + +- [ ] `createAiToolDraft()`에 문자열 필드 매핑을 추가한다. +- [ ] `exampleQueries`는 줄바꿈, `tags`는 쉼표 구분 문자열로 변환한다. +- [ ] 누락된 값은 빈 값 또는 `MCP_TOOL` 기본값으로 처리한다. + +### Task 3: 통합 검증 + +**Files:** +- Verify: Gateway와 V17 관련 전체 변경 + +**Interfaces:** +- Consumes: Task 1~2 결과 +- Produces: 컴파일 및 표준 검증 증거 + +- [ ] Gateway 대상 테스트를 실행한다. +- [ ] `validateToolSchemaV17`을 실행한다. +- [ ] `git diff --check`로 문법적 공백 오류를 확인한다. diff --git a/docs/superpowers/plans/2026-08-12-tool-schema-v17-migration.md b/docs/superpowers/plans/2026-08-12-tool-schema-v17-migration.md new file mode 100644 index 00000000..c44bfdf9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-tool-schema-v17-migration.md @@ -0,0 +1,162 @@ +# Tool Schema V17 Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** BC-DAB-STD-003 V17의 Tool 스키마 필수 항목을 Tool Pod의 등록, Manifest, MCP 노출, Scaffold 및 빌드 검증 전 구간에 일관되게 적용한다. + +**Architecture:** 각 Tool의 업무 명세는 `tool-definitions/{category}/{tool-name}.yml`에서 관리하고, 기동 시 공통 로더가 이를 읽어 기존 어노테이션 정보와 결합한다. 결합된 `ToolMetadata`를 단일 원천으로 Manifest와 MCP Tool을 생성해 Portal/Gateway/직접 MCP 연결 간 메타데이터 차이를 제거한다. + +**Tech Stack:** Java 21, Spring Boot 3.5.11, Spring AI MCP 1.1.8, Jackson YAML/JSON, Gradle, JUnit 5, AssertJ + +## Global Constraints + +- Tool `name`은 `^[a-z][a-z0-9_]{2,63}$`를 만족한다. +- V17 필수 14개 항목을 누락 없이 제공한다. +- `example_queries`는 3~10개이며 Tool 이름을 직접 포함하지 않는다. +- `parameters_schema`는 루트 `type: object`, `additionalProperties: false`이고 모든 property에 description을 둔다. +- `outputSchema`를 명시한 Tool만 출력 검증하며 기존 우선순위를 유지한다. +- 기존 SSE/Streamable HTTP 전송, trace-id/request-id/employee-id, MCI/HTTP 호출 흐름은 변경하지 않는다. +- 기존 미추적 WCM/HMCI 파일을 삭제하거나 덮어쓰지 않는다. +- `register=false`인 Tool을 임의로 운영 등록 상태로 변경하지 않는다. + +--- + +### Task 1: V17 Tool Definition 모델과 로더 + +**Files:** +- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDescription.java` +- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinition.java` +- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepository.java` +- Create: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepositoryTest.java` +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java` + +**Interfaces:** +- Produces: `Optional findByName(String name)` 및 `ToolMetadata`의 V17 필드 접근자. +- Consumes: classpath `tool-definitions/**/*.yml`과 Jackson YAML. + +- [ ] **Step 1: Write failing loader and validation tests** + + 빈 필수 설명, 2개 이하 예시 질의, 잘못된 name, object가 아닌 schema를 거부하고 정상 YAML을 로드하는 테스트를 작성한다. + +- [ ] **Step 2: Run tests and verify RED** + + Run: `./gradlew.bat :dap-was-lib:test --tests "*ToolDefinitionRepositoryTest"` + Expected: FAIL because V17 model/repository does not exist. + +- [ ] **Step 3: Implement minimal immutable models and classpath loader** + + `ToolDescription(function, whenToUse, whenNotToUse, ioLimits)`와 V17 전체 필드를 가진 `ToolDefinition`을 만들고 기동 시 한 번 로드·검증해 name 기준 불변 Map으로 캐시한다. + +- [ ] **Step 4: Run tests and verify GREEN** + + Run: `./gradlew.bat :dap-was-lib:test --tests "*ToolDefinitionRepositoryTest"` + Expected: PASS. + +### Task 2: Registry 수집과 Manifest 표준 매핑 + +**Files:** +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java` +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestItem.java` +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestMeta.java` +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestService.java` +- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSenderTest.java` +- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java` + +**Interfaces:** +- Consumes: `ToolDefinitionRepository.findByName`. +- Produces: V17 description, display description, examples, owner/version/interface/environment metadata가 포함된 `ToolMetadata`와 `/manifest` 응답. + +- [ ] **Step 1: Add failing enrichment and manifest mapping tests** +- [ ] **Step 2: Run targeted tests and verify RED** + + Run: `./gradlew.bat :dap-was-lib:test --tests "*ToolRegistryHeartbeatSenderTest" --tests "*ToolManifestServiceTest"` + +- [ ] **Step 3: Merge annotation runtime data with definition YAML** + + 호출 주소·상태는 런타임 값, 업무 설명·예시·소유조직은 YAML 값을 사용하며 필수 정의가 없는 등록 대상 Tool은 기동 검증에서 실패시킨다. + +- [ ] **Step 4: Map Manifest title/description/inputSchema/outputSchema/annotations/_meta** +- [ ] **Step 5: Run targeted tests and verify GREEN** + +### Task 3: MCP Tool 직접 노출과 Gateway 동기화 + +**Files:** +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java` +- Create or Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizerTest.java` +- Modify: `dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSpecificationFactory.java` +- Modify: `dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactoryTest.java` + +**Interfaces:** +- Consumes: enriched `ToolMetadata`. +- Produces: 동일한 MCP `name`, `title`, 합성 description, input/output schema, readOnly/destructive/idempotent annotations, `_meta`. + +- [ ] **Step 1: Add failing parity tests for Pod and Gateway MCP specs** +- [ ] **Step 2: Run both module tests and verify RED** +- [ ] **Step 3: Implement shared metadata-to-MCP mapping without changing transport** +- [ ] **Step 4: Run both module tests and verify GREEN** + +### Task 4: 기존 Tool 전체 정의와 이름 정규화 + +**Files:** +- Create: `dap-was-sms/src/main/resources/tool-definitions/**/*.yml` +- Create: `dap-was-oth/src/main/resources/tool-definitions/**/*.yml` +- Modify: all `dap-was-sms/src/main/java/**/*UseCase*.java` containing `@McpTool` +- Modify: all `dap-was-oth/src/main/java/**/*UseCase*.java` containing `@McpTool` +- Modify: matching tests and mock fixture keys + +**Interfaces:** +- Consumes: Task 1 YAML format. +- Produces: every discovered Tool has one unique V17 definition. + +- [ ] **Step 1: Add failing repository-wide compliance test** + + 모든 `@McpTool` name에 정확히 하나의 정의가 있고 필수 필드/예시 수/name 규칙을 만족하는지 검사한다. + +- [ ] **Step 2: Run compliance test and verify RED** +- [ ] **Step 3: Rename invalid names and add complete definitions** + + `smp_exchangeRate_inquiry`는 `smp_exchange_inquiry`, `cmm_commonCode_lookup`는 `cmm_comcode_lookup`로 바꾸고 나머지 호출명은 호왘성을 위해 유지한다. + +- [ ] **Step 4: Update tests/fixtures and verify GREEN** + +### Task 5: Scaffold V17 생성 지원 + +**Files:** +- Modify: `dap-gateway/src/main/resources/static/admin/scaffold.html` +- Modify: Scaffold request DTO/controller files discovered under `dap-gateway/src/main/java` +- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java` +- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java` + +**Interfaces:** +- Consumes: 입력한 function/when-to-use/when-not-to-use/io-limits/display-description/examples/owner/tags/hints. +- Produces: Java 소스, schemas, V17 tool-definition YAML. + +- [ ] **Step 1: Add failing scaffold generation tests** +- [ ] **Step 2: Verify RED** +- [ ] **Step 3: Add form fields, validation, preview and YAML generation** +- [ ] **Step 4: Verify GREEN including duplicate DTO-field regression tests** + +### Task 6: Gradle 품질 게이트와 문서 + +**Files:** +- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunner.java` +- Create: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunnerTest.java` +- Modify: `build.gradle` +- Modify: `README.md` + +**Interfaces:** +- Produces: `validateToolSchemaV17` Gradle task; `bootJar` before validation; file/Tool/field가 표시되는 실패 메시지. + +- [ ] **Step 1: Add failing validation runner tests** +- [ ] **Step 2: Verify RED** +- [ ] **Step 3: Implement runner and wire `bootJar.dependsOn(validateToolSchemaV17)`** +- [ ] **Step 4: Document V17 fields, examples, Scaffold, compatibility and commands** +- [ ] **Step 5: Run targeted and full verification** + + Run: + - `./gradlew.bat :dap-was-lib:test` + - `./gradlew.bat :dap-was-sms:test :dap-was-oth:test :dap-gateway:test` + - `./gradlew.bat validateMcpToolNames validateToolSchemaV17` + - `./gradlew.bat clean build` + + Expected: all tasks succeed with zero test failures. diff --git a/docs/superpowers/specs/2026-08-12-scaffold-ai-v17-metadata-design.md b/docs/superpowers/specs/2026-08-12-scaffold-ai-v17-metadata-design.md new file mode 100644 index 00000000..2e9624bd --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-scaffold-ai-v17-metadata-design.md @@ -0,0 +1,22 @@ +# Scaffold AI V17 Metadata Design + +## Goal + +`AI로 Tool 채우기` 실행 시 Tool 기본 정보와 입출력 필드뿐 아니라 Tool Schema V17 Metadata도 함께 생성하고 화면에 반영한다. + +## Design + +- `/api/v1/scaffold/tool-draft`의 AI JSON 계약에 `functionDescription`, `displayDescription`, `whenToUse`, `whenNotToUse`, `ioLimits`, `exampleQueries`, `tags`, `ownerOrg`를 추가한다. +- 서버는 문자열을 trim하고 목록 필드는 비어 있는 항목을 제거하여 반환한다. +- `scaffold.html`은 응답받은 V17 값을 같은 이름의 폼 필드에 채운다. 목록은 화면의 기존 입력 규칙에 맞게 줄바꿈 또는 쉼표 문자열로 변환한다. +- Legacy Interface ID와 Client System Code는 기존 방침대로 AI가 생성하지 않는다. + +## Error Handling + +- V17 필드가 누락된 과거 형식의 AI 응답도 역직렬화할 수 있게 하되, 서버에서 업무 설명을 기반으로 안전한 기본값을 생성한다. +- AI가 빈 배열 또는 공백 항목을 반환하면 기본 예시 질의, 카테고리 태그, 기본 담당 조직을 적용한다. + +## Verification + +- Mock AI 응답을 사용하는 MVC 테스트로 모든 V17 필드가 API 응답에 포함되는지 검증한다. +- Gateway 테스트와 Tool Schema V17 검증을 실행한다. diff --git a/docs/superpowers/specs/2026-08-12-tool-schema-v17-migration-design.md b/docs/superpowers/specs/2026-08-12-tool-schema-v17-migration-design.md new file mode 100644 index 00000000..9e58256e --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-tool-schema-v17-migration-design.md @@ -0,0 +1,170 @@ +# Tool Schema V17 일괄 전환 설계 + +## 1. 목표 + +BC-DAB-STD-003 V17의 도구 스키마 요구사항을 현재 Java Tool Pod 구조에 일괄 적용한다. 표준 메타데이터를 단일 원본으로 관리하고, 동일 정보가 MCP Tool 정의, Tool Manifest, Gateway Registry, Portal 검색, Scaffold 및 빌드 검증에 일관되게 사용되도록 한다. + +기존 Tool 호출명보다 V17 표준 준수를 우선한다. 이름이 변경되는 Tool은 Agent와 Portal 등록도 함께 갱신해야 한다. + +## 2. 표준 원본 + +각 Tool은 모듈의 `src/main/resources/tool-definitions/{category}/{tool-name}.yml`에 표준 정의 파일을 가진다. + +필수 항목은 다음과 같다. + +- `name`: `^[a-z][a-z0-9_]{2,63}$` +- `display_name` +- `version` +- `category_key` +- `description.function` +- `description.when_to_use` +- `description.when_not_to_use` +- `description.io_limits` +- `display_description` +- `example_queries`: 실제 사용자 발화 3~10건 +- `read_only` +- `destructive` +- `idempotent` +- `parameters_schema` + +권장·조건부 항목은 `tags`, `legacy_interface_id`, `required_env_keys`, `owner_org`, `output_schema`로 한다. + +Java `@McpTool`은 실행 메서드를 식별하는 용도로 유지한다. 등록 시 표준 정의 파일을 우선 적용하고, 정의 파일이 없는 Tool은 빌드 품질 게이트에서 실패시킨다. + +## 3. 런타임 데이터 흐름 + +1. Tool Pod 기동 시 `@McpTool` 메서드를 스캔한다. +2. Tool 이름으로 표준 정의 파일을 읽는다. +3. annotation의 이름과 표준 정의의 이름이 다르면 기동 실패한다. +4. 입력 Schema는 기존 우선순위를 유지한다. + - `ToolHint.inputSchemaResource` + - 표준 정의의 `parameters_schema` + - DTO 자동 생성 +5. 출력 Schema는 다음 우선순위를 사용한다. + - `ToolHint.outputSchemaResource` + - 표준 정의의 `output_schema` + - `@McpOutputSchema` DTO 자동 생성 +6. Tool Metadata와 Manifest를 생성한다. +7. Tool Pod MCP 서버와 Gateway MCP 서버에 동일한 title, description, inputSchema, outputSchema, annotations, `_meta`를 등록한다. + +모델에 전달되는 `description`은 설명 4요소를 순서대로 결합한다. `example_queries`, 운영 조직, 기간계 ID와 버전은 `_meta`에 두어 모델 프롬프트 토큰을 늘리지 않는다. + +## 4. 메타데이터 및 Manifest 확장 + +`ToolMetadata`에 다음 값을 추가한다. + +- `displayDescription` +- `descriptionFunction` +- `whenToUse` +- `whenNotToUse` +- `ioLimits` +- `exampleQueries` +- `tags` +- `legacyInterfaceId` +- `requiredEnvKeys` +- `ownerOrg` +- `outputSchema` + +Manifest Tool 항목에는 `outputSchema`를 추가한다. `_meta`에는 `version`, `categoryKey`, `exampleQueries`, `tags`, `legacyInterfaceId`, `requiredEnvKeys`, `ownerOrg`, `timeoutMillis`, `enabled`를 제공한다. + +기존 `mciServiceId`는 호환을 위해 유지하되, 표준 Manifest에서는 `legacyInterfaceId`로 노출한다. + +## 5. MCP 매핑 + +- `name` -> `Tool.name` +- `display_name` -> `Tool.title` 및 `annotations.title` +- 설명 4요소 -> `Tool.description` +- `parameters_schema` -> `Tool.inputSchema` +- `output_schema` -> `Tool.outputSchema` +- `read_only` -> `readOnlyHint` +- `destructive` -> `destructiveHint` +- `idempotent` -> `idempotentHint` +- 폐쇄망 Tool의 `openWorldHint` -> `false` +- 예시 질의와 운영 메타 -> `Tool._meta` 및 Manifest `_meta` + +행위 힌트는 인가 수단으로 사용하지 않는다. 기존 Gateway/Tool 권한 검증이 실제 접근을 차단한다. + +## 6. Tool 이름 전환 + +모든 이름을 영문 소문자 snake_case 3~64자로 통일한다. 현재 확인된 변경 대상은 다음과 같다. + +- `smp_exchangeRate_inquiry` -> `smp_exchange_inquiry` +- `cmm_commonCode_lookup` -> `cmm_comcode_lookup` + +나머지 Tool도 같은 정규식과 `category_service_action` 의미 구조로 검증한다. MCI/EIMS 인터페이스 ID는 Tool명으로 사용하지 않고 `legacy_interface_id`에 저장한다. + +## 7. 설명과 유사 Tool 경계 + +현재 모든 Tool에 설명 4요소를 작성한다. `when_not_to_use`는 유사 Tool이 있으면 실제 Tool명을 양방향으로 기재한다. 유사 Tool이 없으면 `없음`을 명시한다. + +자기 홍보 문구와 강제 선택 문구는 금지한다. 예시 질의에는 Tool 이름을 포함하지 않는다. + +## 8. 품질 게이트 + +Gradle의 기존 Tool 이름 검증을 V17 검증으로 확장한다. `check`와 `bootJar`가 V17 검증에 의존하도록 한다. + +다음을 위반하면 파일과 항목을 표시하고 빌드를 실패시킨다. + +- 필수 14개 항목 누락 +- 이름 정규식 위반 또는 중복 +- annotation 이름과 정의 파일 이름 불일치 +- 설명 4요소 누락 +- 예시 질의 3~10건 위반 또는 Tool명 포함 +- 자기 홍보 문구 포함 +- 입력 필드 description 누락 +- `additionalProperties: false` 미설정 +- `read_only=true`와 `destructive=true` 동시 설정 +- 등록 Tool의 정의 파일 누락 또는 사용되지 않는 정의 파일 존재 +- outputSchema가 선언된 경우 유효하지 않은 구조 + +플랫폼이 주입하는 trace ID, request ID, employee ID 및 인증 값은 Tool 입력 Schema에 포함하지 않는다. + +## 9. Scaffold + +Scaffold 화면과 생성기는 다음 값을 받는다. + +- Tool명 또는 Base Name +- 표시명과 화면 설명 +- 설명 4요소 +- 예시 질의 3~10건 +- category, tags, 담당 조직, 기간계 ID +- 읽기 전용·파괴적·멱등 힌트 +- 입력·출력 필드와 Schema 제약 + +생성 결과에 Java UseCase/DTO/Converter/Client, Tool 정의 YAML, 필요 시 JSON Schema Resource, 테스트 및 Mock 응답을 포함한다. 생성 직후 V17 검증을 실행하고 결과를 화면에 표시한다. + +## 10. 기존 Tool 일괄 마이그레이션 + +SMS와 OTH 모듈의 모든 `@McpTool`을 대상으로 정의 파일을 생성한다. 기존 title, description, DTO, 연동 코드와 테스트를 참고하여 필드를 작성한다. 정보가 소스에서 확정되지 않는 경우 다음 보수적 기본값을 사용한다. + +- `owner_org`: `MCP_TOOL` +- 유사 Tool 없음: `when_not_to_use: 없음` +- 내부 연동: `openWorldHint: false` +- 조회성 이름·구현: 읽기 전용 true, 파괴적 false, 멱등 true +- 등록·발송·처리성 구현: 읽기 전용 false, 파괴적 true, 멱등 false + +`register=false`는 초안/로컬 Tool 상태로 유지한다. 중앙 Registry에 올릴 Tool만 승인 후 `register=true`로 전환하며, 이번 변경에서 임의로 운영 등록을 활성화하지 않는다. + +사용자가 별도로 작업 중인 미추적 WCM/HMCI 파일은 덮어쓰지 않는다. 스캔된 WCM Tool용 정의 파일과 검증 지원만 추가한다. + +## 11. 호환성과 영향 + +이름이 바뀐 Tool은 기존 호출명으로 호출할 수 없다. Portal, DeepAgentBuilder, Agent 설정, 테스트 데이터 및 Mock mapping에서 이름을 함께 변경한다. + +MCP SSE/Streamable HTTP 전송 경로, MCI/HTTP 연동 로직, trace/request ID 흐름은 변경하지 않는다. 이번 범위는 Tool 정의·등록·검색용 메타데이터와 검증에 한정한다. + +기존 JSON input/output Schema Resource 방식은 유지한다. 복잡한 Tool은 Resource를 사용하고 단순 Tool은 DTO 기반 자동 생성을 계속 사용할 수 있다. + +## 12. 테스트 기준 + +- 표준 정의 로더 단위 테스트 +- 필수 필드 및 반려 조건별 검증 테스트 +- description 렌더링과 `_meta` 매핑 테스트 +- input/output Schema 우선순위 테스트 +- Tool Manifest 직렬화 테스트 +- Tool Pod 및 Gateway MCP Tool 정의 동등성 테스트 +- Scaffold 생성물 컴파일 및 V17 검증 테스트 +- 전체 모듈 컴파일·테스트 +- 전체 Tool 정의 V17 검증 통과 + +완료 기준은 모든 등록 대상 Tool이 정의 파일을 보유하고, `check`와 `bootJar`에서 V17 검증을 통과하며, MCP `tools/list`와 Manifest에 동일한 표준 정보가 노출되는 것이다.