From a58af1606e430dcd2ef3b5771663e9257e701cb3 Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 4 Aug 2026 15:58:59 +0900 Subject: [PATCH] feat: publish tool manifest endpoint --- README.md | 105 +++++++++++++- .../dap/lib/annotation/McpFunction.java | 9 ++ .../dap/lib/config/McpProperties.java | 7 + .../dap/lib/util/ToolScaffolder.java | 21 ++- .../shinhanlife/dap/mcc/dto/ToolMetadata.java | 2 + .../mcc/manifest/ToolManifestAnnotations.java | 10 ++ .../dap/mcc/manifest/ToolManifestItem.java | 15 ++ .../dap/mcc/manifest/ToolManifestMeta.java | 5 + .../mcc/manifest/ToolManifestResponse.java | 7 + .../dap/mcc/manifest/ToolManifestService.java | 130 ++++++++++++++++++ .../presentation/ToolManifestController.java | 32 +++++ .../usecase/ToolRegistryHeartbeatSender.java | 5 +- .../dap/lib/util/ToolScaffolderTest.java | 28 ++++ .../mcc/manifest/ToolManifestServiceTest.java | 100 ++++++++++++++ .../src/main/resources/application.yml | 4 + .../src/main/resources/application.yml | 4 + 16 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestAnnotations.java create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestItem.java create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestMeta.java create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestResponse.java create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestService.java create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolManifestController.java create mode 100644 dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java create mode 100644 dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java diff --git a/README.md b/README.md index cc9b178b..f7af9e7f 100644 --- a/README.md +++ b/README.md @@ -466,4 +466,107 @@ MCP SDK 표준 tools/list, tools/call --- -문서에 없는 업무·보안·배포 기준은 임의로 추가하지 말고 AA 및 플랫폼 운영 기준과 먼저 합의합니다. \ No newline at end of file +문서에 없는 업무·보안·배포 기준은 임의로 추가하지 말고 AA 및 플랫폼 운영 기준과 먼저 합의합니다. +## Input/Output Schema 작성 가이드 + +Tool Schema는 Agent가 Tool을 정확히 호출하고, 반환값의 의미를 일관되게 해석하도록 하는 계약입니다. 인증 정보·사번·주민번호 등 민감정보(PII)는 Input/Output Schema와 Tool 응답에 포함하지 않습니다. + +### Input Schema + +Input Schema는 Agent가 Tool에 전달하는 파라미터의 이름, 타입, 필수 여부, 허용값, 형식 등을 정의합니다. + +적용 우선순위는 다음과 같습니다. + +1. `inputSchemaResource` — 복잡한 규칙을 담은 JSON Schema 리소스 +2. `inputSchema` — 어노테이션에 직접 선언한 JSON Schema +3. 요청 DTO 필드의 `@McpValidation` — 자동 JSON Schema 생성 + +단순한 요청 DTO는 `@McpValidation`만으로 관리합니다. + +```java +public class ClaimSearchRequest { + + @McpValidation(required = true, pattern = "^CLM[0-9]{13}$") + private String claimNo; + + @McpValidation(minimum = 1, maximum = 100) + private Integer size; +} +``` + +### Output Schema + +Output Schema는 Tool이 반환하는 결과의 타입과 의미를 정의합니다. `BusinessToolController`는 Tool 실행 후 반환값을 Output Schema 기준으로 검증합니다. + +적용 우선순위는 다음과 같습니다. + +1. `outputSchemaResource` — 조건부 필드·중첩 배열 등 복잡한 규칙을 담은 JSON Schema 리소스 +2. `outputSchema` — 어노테이션에 직접 선언한 JSON Schema +3. 반환 DTO의 `@McpOutputSchema`와 필드 `@McpValidation` — 자동 JSON Schema 생성 +4. 위 설정이 모두 없으면 Output Schema 검증을 수행하지 않음 + +따라서 단순한 응답은 별도 `outputSchemaResource` 없이 반환 DTO에 `@McpOutputSchema`를 선언하면 됩니다. `null`이 정상 값일 수 있는 필드는 `nullable = true`를 반드시 지정합니다. + +```java +@McpOutputSchema +public class ClaimSearchResponse { + + @McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"}) + private String resultCode; + + @McpValidation(nullable = true, minimum = 0) + private Long approvedAmount; +} +``` + +### 복잡한 Schema는 Tool 모듈별 리소스로 관리 + +조건부 응답, 중첩 DTO, 배열 정렬 기준처럼 어노테이션만으로 표현하기 어려운 규칙은 Tool Core가 아니라 각 Tool 모듈의 리소스에 JSON Schema로 둡니다. + +```text +src/main/resources/ +└─ tool-schemas/ + └─ {categoryKey}/ + ├─ claim-search-resource-input-schema.json + └─ claim-search-resource-output-schema.json +``` + +예를 들어 `categoryKey`가 `cmm`이면 아래와 같이 선언합니다. + +```java +@McpFunction( + name = "sample.claim.search.resource", + inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json", + outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json" +) +public ClaimSearchResponse search(ClaimSearchRequest request) { + // ... +} +``` + +`inputSchemaResource`와 `outputSchemaResource`는 복잡한 경우에만 선언합니다. 단순한 Tool까지 JSON 파일을 별도 생성할 필요는 없습니다. + +### Output 설계 규칙 + +- 코드와 표시용 라벨을 함께 반환합니다. 예: `status` + `statusLabel` +- `null`이 정상인 값은 의미를 설명에 명시하고 DTO에는 `nullable = true`를 설정합니다. +- 조건부 필드는 어떤 조건에서 값이 존재하는지 JSON Schema에 명시합니다. +- 배열은 정렬 기준을 설명에 명시합니다. 예: `접수일 내림차순` +- 목록 응답에는 추가 조회 여부를 나타내는 `hasMore`를 포함합니다. +- 민감정보는 마스킹보다 **응답에서 제외**하는 것을 우선합니다. + +### 실행 로그 및 확인 + +Tool 실행이 끝나면 아래 로그는 Schema 정의가 아니라 **검증을 통과한 실제 최종 응답값**을 출력합니다. + +```text +[Tool -> MCP Gateway] Output Schema Result: { ... } +``` + +따라서 로그에도 실제 응답이 남으므로, 응답 DTO와 Output Schema에 민감정보가 포함되지 않도록 설계해야 합니다. + +스키마 리소스와 DTO 기반 자동 Schema는 아래 테스트로 함께 검증할 수 있습니다. + +```powershell +.\gradlew.bat :dap-tool-oth:test --tests "io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequestSchemaTest" +``` diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpFunction.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpFunction.java index 6ee5f338..aa0d6aeb 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpFunction.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpFunction.java @@ -63,5 +63,14 @@ public @interface McpFunction { boolean idempotentHint() default false; boolean openWorldHint() default false; + /** Version exposed as _meta.version in the Tool Manifest. */ + String version() default "1.0.0"; + + /** Maximum execution time exposed as _meta.timeoutMillis in the Tool Manifest. */ + long timeoutMillis() default 300000L; + + /** Whether the Tool is available for MCP exposure. */ + boolean enabled() default true; + // 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms) } diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/config/McpProperties.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/config/McpProperties.java index 2ff9e4f1..1ff72e5c 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/config/McpProperties.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/config/McpProperties.java @@ -26,5 +26,12 @@ import java.util.Map; public class McpProperties { private String namespace; + private Manifest manifest = new Manifest(); + + @Data + public static class Manifest { + private String bundleId; + private String namePrefix; + } } \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java index 7ac0ebd4..f559e527 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java @@ -166,6 +166,8 @@ public class ToolScaffolder { package %s.dto; import com.fasterxml.jackson.annotation.JsonInclude; + import io.shinhanlife.dap.lib.annotation.McpOutputSchema; + import io.shinhanlife.dap.lib.annotation.McpValidation; import lombok.Data; /** @@ -183,16 +185,22 @@ public class ToolScaffolder { * */ @Data + @McpOutputSchema @JsonInclude(JsonInclude.Include.NON_NULL) public class %sResponse { - private String status; - private String message; - // TODO: Add response fields here + @McpValidation(required = true) + private String resultCode; + + @McpValidation(maxLength = 200, nullable = true) + private String resultMessage; + + // TODO: Add response fields here. Do not include PII in the Tool response. } """.formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName); Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent); - String toolName = baseName.isEmpty() ? baseName : Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1); + String rawToolName = baseName.isEmpty() ? baseName : Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1); + String toolName = group.toLowerCase() + "." + rawToolName; String serviceInterfaceContent = """ package %s.usecase; @@ -214,7 +222,10 @@ public class ToolScaffolder { mappingId = "%s", register = %s, requiresApproval = false, - openWorldHint = true + openWorldHint = true, + version = "1.0.0", + timeoutMillis = 300000L, + enabled = true ) Object execute(%sRequest req); } diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java index 1d0f0ac1..9ab09903 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java @@ -31,6 +31,8 @@ public class ToolMetadata { // 1. Tool 기본 정보 private String uid; // UUID 형식의 고유 식별자 private String semver; // 버전 (예: 1.0.0) + private Long timeoutMillis; + private Boolean enabled; private String displayName; // 사람이 읽는 라벨 (1-128자) private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool) private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능) diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestAnnotations.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestAnnotations.java new file mode 100644 index 00000000..35c96654 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestAnnotations.java @@ -0,0 +1,10 @@ +package io.shinhanlife.dap.mcc.manifest; + +/** Behaviour hints exposed by the Tool Service manifest. */ +public record ToolManifestAnnotations( + String title, + boolean readOnlyHint, + boolean destructiveHint, + boolean idempotentHint, + boolean openWorldHint) { +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestItem.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestItem.java new file mode 100644 index 00000000..9f9b8e09 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestItem.java @@ -0,0 +1,15 @@ +package io.shinhanlife.dap.mcc.manifest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** One MCP Tool declaration published by a Tool Service. */ +public record ToolManifestItem( + String name, + String endpoint, + String title, + String description, + Map inputSchema, + ToolManifestAnnotations annotations, + @JsonProperty("_meta") ToolManifestMeta meta) { +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestMeta.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestMeta.java new file mode 100644 index 00000000..e99ac6d8 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestMeta.java @@ -0,0 +1,5 @@ +package io.shinhanlife.dap.mcc.manifest; + +/** Operational metadata exposed by the Tool Service manifest. */ +public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) { +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestResponse.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestResponse.java new file mode 100644 index 00000000..891aaf65 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestResponse.java @@ -0,0 +1,7 @@ +package io.shinhanlife.dap.mcc.manifest; + +import java.util.List; + +/** Top-level response for GET /tool-manifest. */ +public record ToolManifestResponse(String bundleId, String revision, List tools) { +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestService.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestService.java new file mode 100644 index 00000000..aa3d42db --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/manifest/ToolManifestService.java @@ -0,0 +1,130 @@ +package io.shinhanlife.dap.mcc.manifest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dap.lib.config.McpProperties; +import io.shinhanlife.dap.mcc.dto.ToolMetadata; +import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** Builds the Tool Service owned manifest consumed by the MCP server. */ +@Service +public class ToolManifestService { + + private static final long DEFAULT_TIMEOUT_MILLIS = 300000L; + private final Supplier> toolSupplier; + private final ObjectMapper objectMapper; + private final McpProperties properties; + private String lastFingerprint; + private String lastRevision; + + @Autowired + public ToolManifestService(ToolRegistryHeartbeatSender heartbeatSender, ObjectMapper objectMapper, + McpProperties properties) { + this(heartbeatSender::getAllScannedTools, objectMapper, properties); + } + + ToolManifestService(Supplier> toolSupplier, ObjectMapper objectMapper, + McpProperties properties) { + this.toolSupplier = toolSupplier; + this.objectMapper = objectMapper; + this.properties = properties; + } + + public ToolManifestResponse currentManifest() { + String bundleId = properties.getManifest() == null ? null : properties.getManifest().getBundleId(); + if (bundleId == null || bundleId.isBlank()) { + throw new IllegalStateException("mcp.manifest.bundle-id must be configured"); + } + + List tools = toolSupplier.get().stream() + .map(this::toManifestItem) + .sorted(Comparator.comparing(ToolManifestItem::name)) + .toList(); + validate(tools); + return new ToolManifestResponse(bundleId, revision(bundleId, tools), tools); + } + + private ToolManifestItem toManifestItem(ToolMetadata tool) { + String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank() + ? tool.getName() : tool.getDisplayName(); + Map schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema(); + return new ToolManifestItem( + tool.getName(), endpoint(tool), title, tool.getDescription(), schema, + 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())); + } + + private void validate(List tools) { + String namePrefix = properties.getManifest() == null ? null : properties.getManifest().getNamePrefix(); + Set names = new LinkedHashSet<>(); + for (ToolManifestItem tool : tools) { + if (tool.name() == null || tool.name().isBlank()) { + throw new IllegalStateException("Tool manifest contains a blank tool name"); + } + if (!names.add(tool.name())) { + throw new IllegalStateException("Tool manifest contains duplicate tool name: " + tool.name()); + } + if (namePrefix != null && !namePrefix.isBlank() && !tool.name().startsWith(namePrefix)) { + throw new IllegalStateException("Tool name does not match mcp.manifest.name-prefix: " + tool.name()); + } + if (!"object".equals(tool.inputSchema().get("type"))) { + throw new IllegalStateException("Tool inputSchema root type must be object: " + tool.name()); + } + } + } + + private synchronized String revision(String bundleId, List tools) { + String fingerprint = fingerprint(bundleId, tools); + if (!fingerprint.equals(lastFingerprint)) { + long nextTimestamp = System.currentTimeMillis(); + if (lastRevision != null) { + nextTimestamp = Math.max(nextTimestamp, Long.parseLong(lastRevision) + 1); + } + lastFingerprint = fingerprint; + lastRevision = Long.toString(nextTimestamp); + } + return lastRevision; + } + + private String fingerprint(String bundleId, List tools) { + try { + return objectMapper.writeValueAsString(Map.of("bundleId", bundleId, "tools", tools)); + } catch (Exception exception) { + throw new IllegalStateException("Failed to build Tool manifest revision source", exception); + } + } + + private String endpoint(ToolMetadata tool) { + if (tool.getEndpoint() != null && !tool.getEndpoint().isBlank()) { + return tool.getEndpoint(); + } + if (tool.getPodUrl() == null || tool.getPodUrl().isBlank()) { + return "/mcp/" + tool.getName(); + } + return tool.getPodUrl().replaceAll("/+$", "") + "/mcp/" + tool.getName(); + } + private Map emptySchema() { + return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false); + } + + private boolean isTrue(Boolean value) { + return Boolean.TRUE.equals(value); + } + + private String defaultString(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolManifestController.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolManifestController.java new file mode 100644 index 00000000..6ccdf904 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolManifestController.java @@ -0,0 +1,32 @@ +package io.shinhanlife.dap.mcc.presentation; + +import io.shinhanlife.dap.mcc.manifest.ToolManifestResponse; +import io.shinhanlife.dap.mcc.manifest.ToolManifestService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** Read-only Tool Service manifest endpoint for MCP background discovery. */ +@RestController +public class ToolManifestController { + + private final ToolManifestService toolManifestService; + + public ToolManifestController(ToolManifestService toolManifestService) { + this.toolManifestService = toolManifestService; + } + + @GetMapping(value = "/tool-manifest", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getManifest( + @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) { + ToolManifestResponse manifest = toolManifestService.currentManifest(); + String eTag = '"' + manifest.revision() + '"'; + if (eTag.equals(ifNoneMatch)) { + return ResponseEntity.status(304).eTag(eTag).build(); + } + return ResponseEntity.ok().eTag(eTag).body(manifest); + } +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/usecase/ToolRegistryHeartbeatSender.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/usecase/ToolRegistryHeartbeatSender.java index 95f32f49..1591ce3a 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/usecase/ToolRegistryHeartbeatSender.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/usecase/ToolRegistryHeartbeatSender.java @@ -104,12 +104,15 @@ public class ToolRegistryHeartbeatSender { meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString()); meta.setDisplayName(baseName); meta.setName(subToolName); - meta.setSemver("1.0.0"); + meta.setSemver(functionAnnotation.version()); + meta.setTimeoutMillis(functionAnnotation.timeoutMillis()); + meta.setEnabled(functionAnnotation.enabled()); meta.setDescription(functionAnnotation.description()); meta.setCategoryKey(toolAnnotation.categoryKey()); meta.setIntegrationType(toolAnnotation.routingType()); meta.setMciServiceId(functionAnnotation.mappingId()); meta.setPodUrl(podUrl); + meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName); boolean isVisible = functionAnnotation.visible(); meta.setVisible(isVisible); diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java new file mode 100644 index 00000000..2cbe3057 --- /dev/null +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java @@ -0,0 +1,28 @@ +package io.shinhanlife.dap.lib.util; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class ToolScaffolderTest { + + @Test + void generatesManifestReadyToolAndOutputSchemaDto() throws Exception { + String moduleName = "build/scaffold-manifest-test"; + + ToolScaffolder.scaffold("claim search", "CLM0001", "청구 조회", "cmm", "HTTP", moduleName, + "tester", "2026.08.04", true, null); + + Path root = Path.of(moduleName, "src/main/java/io/shinhanlife/dap/mcc/biz/cmm"); + String useCase = Files.readString(root.resolve("usecase/ClaimSearchUseCase.java")); + String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java")); + + assertTrue(useCase.contains("name = \"cmm.claimSearch\"")); + assertTrue(useCase.contains("version = \"1.0.0\"")); + assertTrue(useCase.contains("timeoutMillis = 300000L")); + assertTrue(response.contains("@McpOutputSchema")); + assertTrue(response.contains("@McpValidation")); + } +} diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java new file mode 100644 index 00000000..044ac69c --- /dev/null +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java @@ -0,0 +1,100 @@ +package io.shinhanlife.dap.mcc.manifest; + +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 io.shinhanlife.dap.lib.config.McpProperties; +import io.shinhanlife.dap.mcc.dto.ToolMetadata; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ToolManifestServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void buildsStandardManifestAndDerivesRevisionFromToolDefinition() { + McpProperties properties = manifestProperties("insurance-processing", "processing."); + ToolManifestService service = new ToolManifestService( + () -> List.of(tool("processing.contract.inquiry", "1.2.0", 3000)), objectMapper, properties); + + ToolManifestResponse manifest = service.currentManifest(); + + assertEquals("insurance-processing", manifest.bundleId()); + assertTrue(manifest.revision().matches("\\d+")); + assertEquals(1, manifest.tools().size()); + ToolManifestItem item = manifest.tools().getFirst(); + assertEquals("processing.contract.inquiry", item.name()); + assertEquals("http://tool-processing.ax-hub.svc.cluster.local:8080/mcp/processing.contract.inquiry", + item.endpoint()); + assertEquals("계약 조회", item.title()); + assertEquals("object", item.inputSchema().get("type")); + assertTrue(item.annotations().readOnlyHint()); + assertEquals("1.2.0", item.meta().version()); + assertEquals(3000, item.meta().timeoutMillis()); + } + + @Test + void changesRevisionWhenToolDefinitionChanges() { + McpProperties properties = manifestProperties("insurance-processing", "processing."); + ToolManifestService before = new ToolManifestService( + () -> List.of(tool("processing.contract.inquiry", "1.2.0", 3000)), objectMapper, properties); + ToolManifestService after = new ToolManifestService( + () -> List.of(tool("processing.contract.inquiry", "1.2.0", 5000)), objectMapper, properties); + + assertTrue(!before.currentManifest().revision().equals(after.currentManifest().revision())); + } + + @Test + void rejectsEntireManifestWhenToolNameDoesNotMatchConfiguredPrefix() { + McpProperties properties = manifestProperties("insurance-processing", "processing."); + ToolManifestService service = new ToolManifestService( + () -> List.of(tool("notification.sms.send", "1.0.0", 3000)), objectMapper, properties); + + IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest); + + assertTrue(error.getMessage().contains("name-prefix")); + } + + @Test + void rejectsEntireManifestWhenToolNamesAreDuplicated() { + McpProperties properties = manifestProperties("insurance-processing", "processing."); + ToolManifestService service = new ToolManifestService( + () -> List.of(tool("processing.contract.inquiry", "1.0.0", 3000), + tool("processing.contract.inquiry", "1.0.1", 3000)), objectMapper, properties); + + IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest); + + assertTrue(error.getMessage().contains("duplicate")); + } + + private McpProperties manifestProperties(String bundleId, String namePrefix) { + McpProperties properties = new McpProperties(); + McpProperties.Manifest manifest = new McpProperties.Manifest(); + manifest.setBundleId(bundleId); + manifest.setNamePrefix(namePrefix); + properties.setManifest(manifest); + return properties; + } + + private ToolMetadata tool(String name, String version, long timeoutMillis) { + return ToolMetadata.builder() + .name(name) + .podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080") + .displayName("계약 조회") + .description("계약번호로 계약 정보를 조회합니다.") + .parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")), + "required", List.of("contractNo"), "additionalProperties", false)) + .semver(version) + .timeoutMillis(timeoutMillis) + .enabled(true) + .readOnlyHint(true) + .destructiveHint(false) + .idempotentHint(true) + .openWorldHint(false) + .build(); + } +} diff --git a/dap-tool-oth/src/main/resources/application.yml b/dap-tool-oth/src/main/resources/application.yml index 4bcfbba3..d51b8126 100644 --- a/dap-tool-oth/src/main/resources/application.yml +++ b/dap-tool-oth/src/main/resources/application.yml @@ -10,6 +10,10 @@ logging: org.apache.kafka: ERROR mcp: namespace: "" + manifest: + bundle-id: tool-oth + # Set the AA-assigned prefix before MCP pull activation (for example: oth.). + name-prefix: "" security: tenant-domains: TESTER-DEV: ALL \ No newline at end of file diff --git a/dap-tool-sms/src/main/resources/application.yml b/dap-tool-sms/src/main/resources/application.yml index bdaafd21..fac526a8 100644 --- a/dap-tool-sms/src/main/resources/application.yml +++ b/dap-tool-sms/src/main/resources/application.yml @@ -10,6 +10,10 @@ logging: org.apache.kafka: ERROR mcp: namespace: "" + manifest: + bundle-id: tool-sms + # Set the AA-assigned prefix before MCP pull activation (for example: sms.). + name-prefix: "" security: tenant-domains: TESTER-DEV: ALL \ No newline at end of file