From 7819b11ce3f66a6fc5dfe4fb643ea4ff8863429c Mon Sep 17 00:00:00 2001 From: jade Date: Mon, 10 Aug 2026 09:24:05 +0900 Subject: [PATCH] feat: opt in to output schema validation --- .../dap/lib/annotation/McpOutputSchema.java | 14 +++++++ .../dap/lib/util/JsonSchemaGenerator.java | 3 +- .../dap/lib/util/ToolSchemaResolver.java | 41 ++++++++----------- .../dap/lib/util/ToolSchemaResolverTest.java | 23 ++++------- 4 files changed, 40 insertions(+), 41 deletions(-) create mode 100644 dap-was-lib/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java new file mode 100644 index 00000000..6d8f8370 --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java @@ -0,0 +1,14 @@ +package io.shinhanlife.dap.lib.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a response DTO whose generated JSON Schema must be exposed and validated for a Tool response. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpOutputSchema { +} \ No newline at end of file diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java index c01230d0..626bbdf2 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/JsonSchemaGenerator.java @@ -76,7 +76,8 @@ public class JsonSchemaGenerator { if (!schemaAnnotation.description().isEmpty() && !fieldSchema.containsKey("description")) { fieldSchema.put("description", schemaAnnotation.description()); } - if (schemaAnnotation.required() && !requiredList.contains(field.getName())) { + if ((schemaAnnotation.required() || schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED) + && !requiredList.contains(field.getName())) { requiredList.add(field.getName()); } if (!schemaAnnotation.pattern().isEmpty()) { diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java index 30207b8a..3d7dc3fc 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java @@ -2,14 +2,13 @@ package io.shinhanlife.dap.lib.util; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -// removed McpOutputSchema +import io.shinhanlife.dap.lib.annotation.McpOutputSchema; +import io.shinhanlife.dap.lib.annotation.ToolHint; import java.io.InputStream; import java.util.Map; import org.springframework.core.io.ClassPathResource; -import io.shinhanlife.dap.lib.annotation.ToolHint; - -/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */ +/** Resolves MCP Tool schemas from resources or DTO metadata. */ public class ToolSchemaResolver { private final ObjectMapper objectMapper; @@ -18,7 +17,8 @@ public class ToolSchemaResolver { this.objectMapper = objectMapper; } - public Map resolve(org.springaicommunity.mcp.annotation.McpTool function, ToolHint hint, Class requestType) { + public Map resolve(org.springaicommunity.mcp.annotation.McpTool function, + ToolHint hint, Class requestType) { if (hint != null && !hint.inputSchemaResource().isBlank()) { return loadResource(hint.inputSchemaResource()); } @@ -26,12 +26,11 @@ public class ToolSchemaResolver { } /** - * Resolves an explicitly declared response schema. - * Response schemas are opt-in so existing tools keep their current response behavior. - * ToolHint.outputSchemaResource()가 있으면 classpath JSON 파일에서 로드하고, - * 없으면 responseType DTO를 분석하여 자동 생성합니다. + * Resolves a response schema only when it is explicitly declared. + * A JSON resource has precedence over a DTO marker annotation. */ - public Map resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, Class responseType, ToolHint hint) { + public Map resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, + Class responseType, ToolHint hint) { if (hint != null && !hint.outputSchemaResource().isBlank()) { return loadResource(hint.outputSchemaResource()); } @@ -39,16 +38,16 @@ public class ToolSchemaResolver { } /** - * Resolves an explicitly declared response schema. - * Response schemas are opt-in so existing tools keep their current response behavior. + * Generates a response schema only for DTOs marked with {@link McpOutputSchema}. */ - public Map resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, Class responseType) { - // Object, Map 등 구체적인 DTO가 아닌 경우 검증 스킵 + public Map resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, + Class responseType) { if (responseType == null || responseType == Object.class || Map.class.isAssignableFrom(responseType) || responseType == Void.class - || responseType == void.class) { + || responseType == void.class + || !responseType.isAnnotationPresent(McpOutputSchema.class)) { return Map.of(); } return JsonSchemaGenerator.generateSchema(responseType); @@ -58,7 +57,7 @@ public class ToolSchemaResolver { * Retained for callers that use only explicit output schemas. */ public Map resolveOutput(org.springaicommunity.mcp.annotation.McpTool function) { - return resolveOutput(function, null); + return Map.of(); } private Map loadResource(String location) { @@ -76,12 +75,4 @@ public class ToolSchemaResolver { throw new IllegalStateException("Failed to load MCP schema resource: " + location, e); } } - - private Map parse(String schema, String source) { - try { - return objectMapper.readValue(schema, new TypeReference<>() { }); - } catch (Exception e) { - throw new IllegalStateException("Failed to parse MCP input schema from " + source, e); - } - } -} +} \ No newline at end of file diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java index f30c0e2c..00a72f94 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolSchemaResolverTest.java @@ -4,6 +4,8 @@ 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.McpOutputSchema; +import io.swagger.v3.oas.annotations.media.Schema; import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -23,14 +25,6 @@ class ToolSchemaResolverTest { assertTrue(properties(schema).containsKey("differentField")); } - @Test - void resolvesExplicitOutputSchema() throws Exception { - Method method = OutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); - Map schema = resolver.resolveOutput(method.getAnnotation(McpTool.class)); - assertEquals(false, schema.get("additionalProperties")); - assertTrue(properties(schema).containsKey("resultCode")); - } - @Test void generatesOutputSchemaFromMarkedResponseDto() throws Exception { Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); @@ -45,10 +39,13 @@ class ToolSchemaResolverTest { @Test void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception { Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); + Map schema = resolver.resolveOutput( method.getAnnotation(McpTool.class), AutomaticRequest.class); + assertTrue(schema.isEmpty()); } + @SuppressWarnings("unchecked") private Map properties(Map schema) { return (Map) schema.get("properties"); @@ -72,19 +69,15 @@ class ToolSchemaResolverTest { } } + @McpOutputSchema static class SimpleResponse { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"SUCCESS", "FAILURE"}) private String resultCode; private String message; } - static class OutputSchemaTool { - @McpTool(name = "oth.test.explicit.search") - void search(AutomaticRequest request) { - } - } - static class AutomaticRequest { private String differentField; } -} +} \ No newline at end of file