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 c7251dd..6ee5f33 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 @@ -39,6 +39,17 @@ public @interface McpFunction { */ // 異붽?: Redis ?먮룞 ?깅줉 諛?Heartbeat ?€???щ? ?쒖뼱 String inputSchemaResource() default ""; + + /** + * Tool response JSON Schema. When unset, output validation is skipped. + */ + String outputSchema() default "{}"; + + /** + * Classpath resource for a complex Tool response JSON Schema. + * This value has priority over outputSchema. + */ + String outputSchemaResource() default ""; boolean register() default false; // 異붽?: ??紐⑸줉 ?몄텧 ?щ? ?쒖뼱 (false ???쇱슦?낆? ?섎굹 紐⑸줉?먯꽌 ?④?) diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java new file mode 100644 index 0000000..cfb37dc --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java @@ -0,0 +1,17 @@ +package io.shinhanlife.dap.lib.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a Tool response DTO for automatic output JSON Schema generation. + * Field constraints are declared with {@link McpValidation}. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface McpOutputSchema { +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpValidation.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpValidation.java index ecf23c8..4353edf 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpValidation.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpValidation.java @@ -31,6 +31,6 @@ public @interface McpValidation { int maxLength() default -1; String[] allowedValues() default {}; String format() default ""; - String defaultValue() default ""; + boolean nullable() default false; String defaultValue() default ""; String[] examples() default {}; } diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java index a90efa0..d95dae6 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java @@ -103,6 +103,14 @@ public class JsonSchemaGenerator { if (validation != null && validation.examples().length > 0) { fieldSchema.put("examples", List.of(validation.examples())); } + if (validation != null && validation.nullable()) { + Map nonNullSchema = new HashMap<>(fieldSchema); + fieldSchema = new HashMap<>(); + fieldSchema.put("anyOf", List.of( + nonNullSchema, + Map.of("type", "null") + )); + } properties.put(field.getName(), fieldSchema); } diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java index 787cbc1..5ea54df 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java @@ -3,7 +3,7 @@ package io.shinhanlife.dap.lib.util; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.lib.annotation.McpFunction; -import java.io.InputStream; +import io.shinhanlife.dap.lib.annotation.McpOutputSchema;import java.io.InputStream; import java.util.Map; import org.springframework.core.io.ClassPathResource; @@ -27,19 +27,44 @@ public class ToolSchemaResolver { return JsonSchemaGenerator.generateSchema(requestType); } + /** + * Resolves an explicitly declared response schema. + * Response schemas are opt-in so existing tools keep their current response behavior. + */ + public Map resolveOutput(McpFunction function, Class responseType) { + if (function != null && !function.outputSchemaResource().isBlank()) { + return loadResource(function.outputSchemaResource()); + } + if (function != null && !function.outputSchema().isBlank() + && !"{}".equals(function.outputSchema().trim())) { + return parse(function.outputSchema(), "McpFunction.outputSchema"); + } + if (responseType != null && responseType.isAnnotationPresent(McpOutputSchema.class)) { + return JsonSchemaGenerator.generateSchema(responseType); + } + return Map.of(); + } + + /** + * Retained for callers that use only explicit output schemas. + */ + public Map resolveOutput(McpFunction function) { + return resolveOutput(function, null); + } + private Map loadResource(String location) { String path = location.startsWith("classpath:") ? location.substring("classpath:".length()) : location; ClassPathResource resource = new ClassPathResource(path); if (!resource.exists()) { - throw new IllegalStateException("MCP input schema resource not found: " + location); + throw new IllegalStateException("MCP schema resource not found: " + location); } try (InputStream inputStream = resource.getInputStream()) { return objectMapper.readValue(inputStream, new TypeReference<>() { }); } catch (Exception e) { - throw new IllegalStateException("Failed to load MCP input schema resource: " + location, e); + throw new IllegalStateException("Failed to load MCP schema resource: " + location, e); } } diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java index daf0018..ec840ed 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java @@ -192,14 +192,30 @@ public class BusinessToolController { } else { methodResult = targetMethod.invoke(targetBean, invokeArgument); } + + Map outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType()); + if (!outputSchema.isEmpty()) { + List outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult); + if (!outputErrors.isEmpty()) { + log.error("[Tool] Output schema validation failed. tool={}, errors={}", + functionName, outputErrors); + Map errorBody = new HashMap<>(); + errorBody.put("code", "INVALID_TOOL_RESPONSE"); + errorBody.put("message", "Tool response does not match its output schema"); + if (finalRequestId != null) { + errorBody.put("request_id", finalRequestId); + } + return ResponseEntity.internalServerError().body(errorBody); + } + } long elapsed = System.currentTimeMillis() - startTime; // 5. 寃곌낵 諛섑솚 (?쒖닔 REST ?묐떟) try { - log.info("[Tool -> MCP Gateway] ?숈쟻 ???ㅽ뻾 寃곌낵 諛섑솚: {}", objectMapper.writeValueAsString(methodResult)); + log.info("[Tool -> MCP Gateway] Output Schema Result: {}", objectMapper.writeValueAsString(methodResult)); } catch (Exception e) { - log.info("[Tool -> MCP Gateway] ?숈쟻 ???ㅽ뻾 寃곌낵 諛섑솚: {}", methodResult); + log.info("[Tool -> MCP Gateway] Output Schema Result: {}", methodResult); } log.info(" [Tool] OUT - trace-id: {}, request-id: {}", traceId, requestId); diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java index 876144c..59c68ff 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/ToolArgumentSchemaValidator.java @@ -21,8 +21,12 @@ public class ToolArgumentSchemaValidator { } public List validate(Map schemaDefinition, Map arguments) throws Exception { + return validateValue(schemaDefinition, arguments); + } + + public List validateValue(Map schemaDefinition, Object value) throws Exception { SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition)); - return schema.validate(objectMapper.writeValueAsString(arguments), InputFormat.JSON); + return schema.validate(objectMapper.writeValueAsString(value), InputFormat.JSON); } } diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java index 6737a00..bc05f05 100644 --- a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java @@ -5,6 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.lib.annotation.McpFunction; +import io.shinhanlife.dap.lib.annotation.McpOutputSchema; +import io.shinhanlife.dap.lib.annotation.McpValidation; import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -15,66 +17,103 @@ class ToolSchemaResolverTest { private final ToolSchemaResolver resolver = new ToolSchemaResolver(new ObjectMapper()); @Test - void usesExplicitSchemaResourceBeforeAutomaticDtoSchema() throws Exception { - Method method = SchemaBackedTool.class.getDeclaredMethod("search", AutoGeneratedRequest.class); - McpFunction function = method.getAnnotation(McpFunction.class); + void usesInlineSchemaBeforeAutomaticDtoSchema() throws Exception { + Method method = InlineSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); - Map schema = resolver.resolve(function, AutoGeneratedRequest.class); + Map schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutomaticRequest.class); assertEquals(false, schema.get("additionalProperties")); - assertTrue(schema.containsKey("anyOf")); - assertEquals(50, ((Map) ((Map) schema.get("properties")).get("size")).get("maximum")); - assertEquals(List.of(Map.of("required", List.of("claimNo"))), schema.get("anyOf")); + assertTrue(!properties(schema).containsKey("differentField")); } + @Test + void generatesSchemaFromRequestDtoWhenNoExplicitSchemaIsConfigured() throws Exception { + Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); - /* Inline schema resolution is covered by the same resolver branch at integration level. - void usesInlineSchemaBeforeAutomaticDtoSchema() throws Exception { - Method method = InlineSchemaTool.class.getDeclaredMethod("search", AutoGeneratedRequest.class); + Map schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutomaticRequest.class); - Map schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutoGeneratedRequest.class); + assertTrue(properties(schema).containsKey("differentField")); + } - assertEquals("object", schema.get("type")); + @Test + void resolvesExplicitOutputSchema() throws Exception { + Method method = OutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); + Map schema = resolver.resolveOutput(method.getAnnotation(McpFunction.class)); assertEquals(false, schema.get("additionalProperties")); - assertTrue(!((Map) schema.get("properties")).containsKey("differentField")); + assertTrue(properties(schema).containsKey("resultCode")); } - */ @Test - void generatesSchemaFromRequestDtoWhenNoExplicitSchemaIsConfigured() throws Exception { - Method method = SchemaBackedTool.AutomaticSchemaTool.class.getDeclaredMethod("search", AutoGeneratedRequest.class); + void generatesOutputSchemaFromMarkedResponseDto() throws Exception { + Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); - Map schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutoGeneratedRequest.class); + Map schema = resolver.resolveOutput( + method.getAnnotation(McpFunction.class), SimpleResponse.class); - assertTrue(((Map) schema.get("properties")).containsKey("differentField")); + assertEquals(List.of("resultCode"), schema.get("required")); + assertEquals(List.of("SUCCESS", "FAILURE"), property(schema, "resultCode").get("enum")); } - static class InlineSchemaTool { + @Test + void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception { + Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); + Map schema = resolver.resolveOutput( + method.getAnnotation(McpFunction.class), AutomaticRequest.class); + assertTrue(schema.isEmpty()); + } + @SuppressWarnings("unchecked") + private Map properties(Map schema) { + return (Map) schema.get("properties"); + } + + @SuppressWarnings("unchecked") + private Map property(Map schema, String name) { + return (Map) properties(schema).get(name); + } - @McpFunction(displayName = "inline", name = "sample.inline", description = "inline", inputSchema = "{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{},\\\"additionalProperties\\\":false}") - void search(AutoGeneratedRequest request) { + static class InlineSchemaTool { + @McpFunction( + displayName = "inline", + name = "sample.inline", + description = "inline schema", + inputSchema = "{\"type\":\"object\",\"properties\":{\"keyword\":{\"type\":\"string\"}},\"additionalProperties\":false}") + void search(AutomaticRequest request) { } } - static class SchemaBackedTool { static class AutomaticSchemaTool { + @McpFunction(displayName = "automatic", name = "sample.automatic", description = "automatic schema") + void search(AutomaticRequest request) { + } + } - @McpFunction(displayName = "automatic", name = "sample.automatic", description = "automatic") - void search(AutoGeneratedRequest request) { + static class AutomaticOutputSchemaTool { + @McpFunction(displayName = "automatic-output", name = "sample.automatic-output", description = "automatic output") + SimpleResponse search(AutomaticRequest request) { + return null; } } + @McpOutputSchema + static class SimpleResponse { + @McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"}) + private String resultCode; + + @McpValidation(maxLength = 200) + private String message; + } + static class OutputSchemaTool { @McpFunction( - displayName = "泥?뎄 議고쉶", - name = "processing.claim.search", - description = "蹂댄뿕湲?泥?뎄瑜?議고쉶?쒕떎.", - inputSchemaResource = "classpath:tool-schemas/claim-search-input-schema.json") - void search(AutoGeneratedRequest request) { + displayName = "output", + name = "sample.output", + description = "output schema", + outputSchema = "{\"type\":\"object\",\"properties\":{\"resultCode\":{\"type\":\"string\"}},\"required\":[\"resultCode\"],\"additionalProperties\":false}") + void search(AutomaticRequest request) { } } - static class AutoGeneratedRequest { + static class AutomaticRequest { private String differentField; } } diff --git a/dap-tool-core/src/test/resources/tool-schemas/claim-search-input-schema.json b/dap-tool-core/src/test/resources/tool-schemas/claim-search-input-schema.json deleted file mode 100644 index e595b42..0000000 --- a/dap-tool-core/src/test/resources/tool-schemas/claim-search-input-schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "claimNo": { - "type": "string", - "pattern": "^CLM[0-9]{13}$" - }, - "size": { - "type": "integer",R - "minimum": 1, - "maximum": 50, - "default": 20 - } - }, - "additionalProperties": false, - "anyOf": [ - { "required": ["claimNo"] } - ] -} diff --git a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchResponse.java b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchResponse.java new file mode 100644 index 0000000..abd2d2d --- /dev/null +++ b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchResponse.java @@ -0,0 +1,88 @@ +package io.shinhanlife.dap.mcc.biz.cmm.dto; + +import io.shinhanlife.dap.lib.annotation.McpOutputSchema; +import io.shinhanlife.dap.lib.annotation.McpParameter; +import io.shinhanlife.dap.lib.annotation.McpValidation; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Claim search response sample. + * Complex response rules are defined by outputSchemaResource; annotations document + * the same simple field constraints for automatic schema generation examples. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@McpOutputSchema +public class ClaimSearchResponse { + + @McpParameter(description = "Execution result code.") + @McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"}) + private String resultCode; + + @McpParameter(description = "User-readable label for resultCode.") + @McpValidation(required = true, maxLength = 100) + private String resultLabel; + + @McpParameter(description = "Current claim processing status code.") + @McpValidation(required = true, allowedValues = { + "RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED", + "APPROVED", "PAID", "REJECTED", "WITHDRAWN" + }) + private String status; + + @McpParameter(description = "User-readable label for status.") + @McpValidation(required = true, maxLength = 100) + private String statusLabel; + + @McpParameter(description = "Approved amount. Null before review; do not interpret null as zero.") + @McpValidation(minimum = 0, nullable = true) + private Long approvedAmount; + + @McpParameter(description = "Present only when status is REJECTED; otherwise null.") + @McpValidation(maxLength = 200, nullable = true) + private String rejectionReason; + + @McpParameter(description = "Claim summaries, ordered by received date descending.") + @McpValidation(required = true) + private List items; + + @McpParameter(description = "True when additional results exist beyond this response.") + @McpValidation(required = true) + private Boolean hasMore; + + @McpParameter(description = "Total number of matched claims.") + @McpValidation(required = true, minimum = 0) + private Integer totalCount; + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ClaimSummary { + + @McpParameter(description = "Claim processing status code.") + @McpValidation(required = true) + private String status; + + @McpParameter(description = "User-readable label for status.") + @McpValidation(required = true) + private String statusLabel; + + @McpParameter(description = "Received date in YYYY-MM-DD format.") + @McpValidation(required = true, format = "date") + private String receivedDate; + + @McpParameter(description = "Approved amount. Null before review; do not interpret null as zero.") + @McpValidation(minimum = 0, nullable = true) + private Long approvedAmount; + } +} \ No newline at end of file diff --git a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/ClaimSearchSchemaSampleUseCase.java b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/ClaimSearchSchemaSampleUseCase.java index 16d1aa0..9a55c1a 100644 --- a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/ClaimSearchSchemaSampleUseCase.java +++ b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/ClaimSearchSchemaSampleUseCase.java @@ -3,6 +3,7 @@ package io.shinhanlife.dap.mcc.biz.cmm.usecase; import io.shinhanlife.dap.lib.annotation.McpFunction; import io.shinhanlife.dap.lib.annotation.McpTool; import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest; +import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchResponse; @McpTool( routingType = "DIRECT", @@ -12,13 +13,14 @@ public interface ClaimSearchSchemaSampleUseCase { @McpFunction( register = false, - displayName = "泥?뎄 議고쉶 JSON Schema ?섑뵆", + displayName = "Claim search JSON Schema sample", name = "sample.claim.search.resource", - description = "inputSchemaResource瑜??ъ슜?섎뒗 泥?뎄 議고쉶 Tool ?섑뵆?낅땲??", - prompt = "泥?뎄踰덊샇 ?먮뒗 怨꾩빟踰덊샇濡?蹂댄뿕湲?泥?뎄瑜?議고쉶?댁쨾.", - inputSchemaResource = "classpath:tool-schemas/claim-search-resource-input-schema.json", + description = "Claim search Tool sample using input and output JSON Schema resources.", + prompt = "Search an insurance claim by claim number or contract number.", + inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json", + outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json", readOnlyHint = true, idempotentHint = true ) - Object search(ClaimSearchRequest request); -} + ClaimSearchResponse search(ClaimSearchRequest request); +} \ No newline at end of file diff --git a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/impl/ClaimSearchSchemaSampleUseCaseImpl.java b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/impl/ClaimSearchSchemaSampleUseCaseImpl.java index e6cd98b..1a817f5 100644 --- a/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/impl/ClaimSearchSchemaSampleUseCaseImpl.java +++ b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/usecase/impl/ClaimSearchSchemaSampleUseCaseImpl.java @@ -1,22 +1,36 @@ package io.shinhanlife.dap.mcc.biz.cmm.usecase.impl; import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest; +import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchResponse; import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase; -import java.util.Map; +import java.util.List; import org.springframework.stereotype.Service; /** - * inputSchemaResource ?곸슜 諛⑹떇??蹂댁뿬二쇰뒗 鍮꾨끂異??섑뵆 Tool?대떎. - * ?ㅼ젣 MCI/EIMS ?곕룞?€ 異붽??섏? ?딅뒗?? + * Non-exposed sample Tool. It does not call MCI or EIMS. */ @Service public class ClaimSearchSchemaSampleUseCaseImpl implements ClaimSearchSchemaSampleUseCase { @Override - public Object search(ClaimSearchRequest request) { - return Map.of( - "message", "inputSchemaResource JSON Schema sample", - "request", request == null ? Map.of() : request - ); + public ClaimSearchResponse search(ClaimSearchRequest request) { + ClaimSearchResponse.ClaimSummary item = ClaimSearchResponse.ClaimSummary.builder() + .status("REVIEWING") + .statusLabel("Under review") + .receivedDate("2026-08-04") + .approvedAmount(null) + .build(); + + return ClaimSearchResponse.builder() + .resultCode("SUCCESS") + .resultLabel("Success") + .status("REVIEWING") + .statusLabel("Under review") + .approvedAmount(null) + .rejectionReason(null) + .items(List.of(item)) + .hasMore(false) + .totalCount(1) + .build(); } -} +} \ No newline at end of file diff --git a/dap-tool-oth/src/main/resources/tool-schemas/claim-search-resource-input-schema.json b/dap-tool-oth/src/main/resources/tool-schemas/cmm/claim-search-resource-input-schema.json similarity index 100% rename from dap-tool-oth/src/main/resources/tool-schemas/claim-search-resource-input-schema.json rename to dap-tool-oth/src/main/resources/tool-schemas/cmm/claim-search-resource-input-schema.json diff --git a/dap-tool-oth/src/main/resources/tool-schemas/cmm/claim-search-resource-output-schema.json b/dap-tool-oth/src/main/resources/tool-schemas/cmm/claim-search-resource-output-schema.json new file mode 100644 index 0000000..f94c661 --- /dev/null +++ b/dap-tool-oth/src/main/resources/tool-schemas/cmm/claim-search-resource-output-schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "description": "Claim search response. This schema intentionally excludes employee identifiers, customer names, contact details, account information, and other PII.", + "properties": { + "resultCode": { + "type": "string", + "enum": ["SUCCESS", "FAILURE"], + "description": "Machine-readable execution result code." + }, + "resultLabel": { + "type": "string", + "description": "User-readable label for resultCode." + }, + "status": { + "type": "string", + "enum": ["RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED", "APPROVED", "PAID", "REJECTED", "WITHDRAWN"], + "description": "Current claim processing status code." + }, + "statusLabel": { + "type": "string", + "description": "User-readable label for status." + }, + "approvedAmount": { + "anyOf": [ + { "type": "number", "minimum": 0 }, + { "type": "null" } + ], + "description": "Approved amount. It is null before review and must not be interpreted as zero." + }, + "rejectionReason": { + "type": ["string", "null"], + "maxLength": 200, + "description": "Has a value only when status is REJECTED. It is null for every other status." + }, + "items": { + "type": "array", + "description": "Claim summaries ordered by receivedDate descending. No personally identifiable information is included.", + "items": { + "type": "object", + "properties": { + "status": { "type": "string", "description": "Claim status code." }, + "statusLabel": { "type": "string", "description": "User-readable label for status." }, + "receivedDate": { "type": "string", "format": "date", "description": "Claim received date." }, + "approvedAmount": { + "anyOf": [ + { "type": "number", "minimum": 0 }, + { "type": "null" } + ], + "description": "Null before review; do not interpret as zero." + } + }, + "required": ["status", "statusLabel", "receivedDate"], + "additionalProperties": false + } + }, + "hasMore": { + "type": "boolean", + "description": "True when additional results exist beyond this response." + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "description": "Total number of matched claims." + } + }, + "required": ["resultCode", "resultLabel", "status", "statusLabel", "items", "hasMore", "totalCount"], + "allOf": [ + { + "if": { + "properties": { "status": { "const": "REJECTED" } }, + "required": ["status"] + }, + "then": { + "properties": { "rejectionReason": { "type": "string", "minLength": 1 } }, + "required": ["rejectionReason"] + } + } + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/dap-tool-oth/src/test/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchRequestSchemaTest.java b/dap-tool-oth/src/test/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchRequestSchemaTest.java index b52e10c..cead062 100644 --- a/dap-tool-oth/src/test/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchRequestSchemaTest.java +++ b/dap-tool-oth/src/test/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchRequestSchemaTest.java @@ -3,7 +3,13 @@ package io.shinhanlife.dap.mcc.biz.cmm.dto; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dap.lib.annotation.McpFunction; import io.shinhanlife.dap.lib.util.JsonSchemaGenerator; +import io.shinhanlife.dap.lib.util.ToolSchemaResolver; +import io.shinhanlife.dap.mcc.biz.cmm.usecase.impl.ClaimSearchSchemaSampleUseCaseImpl; +import io.shinhanlife.dap.mcc.presentation.ToolArgumentSchemaValidator;import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -24,8 +30,59 @@ class ClaimSearchRequestSchemaTest { Map.of("required", List.of("contractNo"))), schema.get("anyOf")); } + @Test + void resolvesSchemaFromToolModuleResource() throws Exception { + Method method = ClaimSearchSchemaSampleUseCase.class + .getDeclaredMethod("search", ClaimSearchRequest.class); + McpFunction function = method.getAnnotation(McpFunction.class); + + Map schema = new ToolSchemaResolver(new ObjectMapper()) + .resolve(function, ClaimSearchRequest.class); + + assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema")); + assertTrue(schema.containsKey("anyOf")); + assertEquals(50, property(schema, "size").get("maximum")); + } + + @Test + void resolvesOutputSchemaFromToolModuleResource() throws Exception { + Method method = ClaimSearchSchemaSampleUseCase.class + .getDeclaredMethod("search", ClaimSearchRequest.class); + McpFunction function = method.getAnnotation(McpFunction.class); + Map schema = new ToolSchemaResolver(new ObjectMapper()).resolveOutput(function); + assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema")); + assertTrue(properties(schema).containsKey("resultCode")); + assertTrue(properties(schema).containsKey("statusLabel")); + assertTrue(properties(schema).containsKey("hasMore")); + assertTrue(schema.containsKey("allOf")); + } + + @Test + void sampleResponseConformsToOutputSchema() throws Exception { + Method method = ClaimSearchSchemaSampleUseCase.class + .getDeclaredMethod("search", ClaimSearchRequest.class); + Map schema = new ToolSchemaResolver(new ObjectMapper()) + .resolveOutput(method.getAnnotation(McpFunction.class)); + + ClaimSearchResponse response = new ClaimSearchSchemaSampleUseCaseImpl().search(new ClaimSearchRequest()); + ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper()); + + assertTrue(validator.validateValue(schema, response).isEmpty()); + } + @Test + void sampleResponseConformsToAutomaticallyGeneratedOutputSchema() throws Exception { + Map schema = JsonSchemaGenerator.generateSchema(ClaimSearchResponse.class); + ClaimSearchResponse response = new ClaimSearchSchemaSampleUseCaseImpl().search(new ClaimSearchRequest()); + ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper()); + + assertTrue(validator.validateValue(schema, response).isEmpty()); + } @SuppressWarnings("unchecked") private Map> properties(Map schema) { return (Map>) schema.get("properties"); } + + private Map property(Map schema, String name) { + return properties(schema).get(name); + } }