From 1e18d2bc47fdd7c14c72cc661db20dfb924361ae Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 28 Jul 2026 14:39:31 +0900 Subject: [PATCH] feat: support nested MCP input schema validation --- .../dap/lib/util/JsonSchemaGenerator.java | 52 ++++++++++++++- .../dap/lib/util/JsonSchemaGeneratorTest.java | 64 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) 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 46d303b2..e48a489b 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 @@ -5,10 +5,14 @@ import com.fasterxml.jackson.annotation.JsonPropertyDescription; import io.shinhanlife.dap.lib.annotation.McpParameter; import io.shinhanlife.dap.lib.annotation.McpValidation; import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** @@ -31,17 +35,23 @@ public class JsonSchemaGenerator { * Java DTO 클래스를 분석하여 MCP 규격의 완전한 JSON Schema를 생성합니다. */ public static Map generateSchema(Class clazz) { + return generateSchema(clazz, new HashSet<>()); + } + + private static Map generateSchema(Class clazz, Set> visiting) { Map schema = new HashMap<>(); schema.put("type", "object"); + if (!visiting.add(clazz)) { + return schema; + } Map properties = new HashMap<>(); List requiredList = new ArrayList<>(); for (Field field : clazz.getDeclaredFields()) { - Map fieldSchema = new HashMap<>(); + Map fieldSchema = createFieldSchema(field, visiting); // 1. 타입 매핑 - fieldSchema.put("type", mapJavaTypeToJsonType(field.getType())); // 2. 어노테이션 기반 설명 추출 McpParameter paramAnnotation = field.getAnnotation(McpParameter.class); @@ -82,9 +92,47 @@ public class JsonSchemaGenerator { schema.put("required", requiredList); } + visiting.remove(clazz); return schema; } + private static Map createFieldSchema(Field field, Set> visiting) { + Class fieldType = field.getType(); + if (isSimpleType(fieldType)) { + return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(fieldType))); + } + if (List.class.isAssignableFrom(fieldType)) { + Map fieldSchema = new HashMap<>(); + fieldSchema.put("type", "array"); + fieldSchema.put("items", generateItemsSchema(field, visiting)); + return fieldSchema; + } + return generateSchema(fieldType, visiting); + } + + private static Map generateItemsSchema(Field field, Set> visiting) { + Type genericType = field.getGenericType(); + if (genericType instanceof ParameterizedType parameterizedType) { + Type itemType = parameterizedType.getActualTypeArguments()[0]; + if (itemType instanceof Class itemClass) { + if (isSimpleType(itemClass)) { + return new HashMap<>(Map.of("type", mapJavaTypeToJsonType(itemClass))); + } + return generateSchema(itemClass, visiting); + } + } + return new HashMap<>(Map.of("type", "object")); + } + + private static boolean isSimpleType(Class clazz) { + return clazz == String.class + || clazz == Integer.class || clazz == int.class + || clazz == Long.class || clazz == long.class + || clazz == Double.class || clazz == double.class + || clazz == Float.class || clazz == float.class + || clazz == Boolean.class || clazz == boolean.class; + } + private static String mapJavaTypeToJsonType(Class clazz) { if (clazz == String.class) return "string"; if (clazz == Integer.class || clazz == int.class) return "integer"; diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java index f5340464..c7e648a8 100644 --- a/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/lib/util/JsonSchemaGeneratorTest.java @@ -1,12 +1,19 @@ package io.shinhanlife.dap.lib.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; import io.shinhanlife.dap.lib.annotation.McpParameter; import io.shinhanlife.dap.lib.annotation.McpValidation; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; /** @@ -36,11 +43,55 @@ class JsonSchemaGeneratorTest { assertEquals(List.of("APPROVE", "REJECT"), properties.get("approvalStatus").get("enum")); } + @Test + void includesNestedDtoConstraintsInGeneratedSchema() { + Map schema = JsonSchemaGenerator.generateSchema(NestedRequest.class); + Map childSchema = property(schema, "child"); + + assertEquals("object", childSchema.get("type")); + assertTrue(required(childSchema).contains("businessDate")); + assertEquals("^\\d{8}$", property(childSchema, "businessDate").get("pattern")); + } + + @Test + void includesNestedDtoSchemaForListItems() { + Map schema = JsonSchemaGenerator.generateSchema(ListRequest.class); + Map itemSchema = map(property(schema, "items").get("items")); + + assertEquals("object", itemSchema.get("type")); + assertTrue(required(itemSchema).contains("businessDate")); + } + + @Test + void validatorRejectsInvalidNestedValue() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) + .getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class))); + Set errors = schema.validate(objectMapper.valueToTree(Map.of( + "child", Map.of("businessDate", "2026-07-28")))); + + assertFalse(errors.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); + } + + @SuppressWarnings("unchecked") + private Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private List required(Map schema) { + return (List) schema.get("required"); + } + private static class ValidatedRequest { @McpParameter(description = "recipient phone number", required = true) @McpValidation(pattern = "^01[0-9]{8,9}$") @@ -54,4 +105,17 @@ class JsonSchemaGeneratorTest { @McpValidation(required = true, allowedValues = {"APPROVE", "REJECT"}) private String approvalStatus; } + + private static class NestedRequest { + private NestedChild child; + } + + private static class ListRequest { + private List items; + } + + private static class NestedChild { + @McpValidation(required = true, pattern = "^\\d{8}$") + private String businessDate; + } }