From 31e6fd605d77f435dfcd922c155cc72387c0cd98 Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 4 Aug 2026 11:24:59 +0900 Subject: [PATCH] feat: support MCP output schemas --- .../dap/lib/annotation/McpFunction.java | 11 ++++ .../dap/lib/annotation/McpOutputSchema.java | 17 ++++++ .../dap/lib/util/ToolSchemaResolver.java | 31 +++++++++- .../presentation/BusinessToolController.java | 16 +++++ .../ToolArgumentSchemaValidator.java | 6 +- .../dap/lib/util/ToolSchemaResolverTest.java | 60 +++++++++++++++++++ .../mcc/biz/cmm/dto/ClaimSearchResponse.java | 34 +++++++++++ .../ClaimSearchSchemaSampleUseCase.java | 2 +- .../claim-search-resource-output-schema.json | 10 ++++ .../cmm/dto/ClaimSearchRequestSchemaTest.java | 10 ++++ 10 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 dap-tool-core/src/main/java/io/shinhanlife/dap/lib/annotation/McpOutputSchema.java create mode 100644 dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchResponse.java create mode 100644 dap-tool-oth/src/main/resources/tool-schemas/claim-search-resource-output-schema.json 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 c7251ddf..6ee5f338 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 00000000..cfb37dca --- /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/util/ToolSchemaResolver.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/lib/util/ToolSchemaResolver.java index 787cbc1d..5ea54dfb 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 daf0018e..77db8400 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,6 +192,22 @@ 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; 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 876144ca..59c68ffe 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 b9f7dcf6..bc05f05d 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,7 +5,10 @@ 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; import org.junit.jupiter.api.Test; @@ -32,11 +35,42 @@ 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(McpFunction.class)); + assertEquals(false, schema.get("additionalProperties")); + assertTrue(properties(schema).containsKey("resultCode")); + } + + @Test + void generatesOutputSchemaFromMarkedResponseDto() throws Exception { + Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); + + Map schema = resolver.resolveOutput( + method.getAnnotation(McpFunction.class), SimpleResponse.class); + + assertEquals(List.of("resultCode"), schema.get("required")); + assertEquals(List.of("SUCCESS", "FAILURE"), property(schema, "resultCode").get("enum")); + } + + @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); + } + static class InlineSchemaTool { @McpFunction( displayName = "inline", @@ -53,6 +87,32 @@ class ToolSchemaResolverTest { } } + 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 = "output", + name = "sample.output", + description = "output schema", + outputSchema = "{\"type\":\"object\",\"properties\":{\"resultCode\":{\"type\":\"string\"}},\"required\":[\"resultCode\"],\"additionalProperties\":false}") + void search(AutomaticRequest request) { + } + } + static class AutomaticRequest { private String differentField; } 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 00000000..f87b6910 --- /dev/null +++ b/dap-tool-oth/src/main/java/io/shinhanlife/dap/mcc/biz/cmm/dto/ClaimSearchResponse.java @@ -0,0 +1,34 @@ +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 lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Simple response DTO sample that generates an MCP output schema from annotations. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@McpOutputSchema +public class ClaimSearchResponse { + + @McpParameter(description = "Tool execution result code.") + @McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"}) + private String resultCode; + + @McpParameter(description = "User-readable execution message.") + @McpValidation(required = true, maxLength = 200) + private String message; + + @McpParameter(description = "Number of matched claims.") + @McpValidation(minimum = 0) + private Integer totalCount; +} \ 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 16d1aa0c..779150c5 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 @@ -17,7 +17,7 @@ public interface ClaimSearchSchemaSampleUseCase { description = "inputSchemaResource를 사용하는 청구 조회 Tool 샘플입니다.", prompt = "청구번호 또는 계약번호로 보험금 청구를 조회해줘.", inputSchemaResource = "classpath:tool-schemas/claim-search-resource-input-schema.json", - readOnlyHint = true, + outputSchemaResource = "classpath:tool-schemas/claim-search-resource-output-schema.json", readOnlyHint = true, idempotentHint = true ) Object search(ClaimSearchRequest request); diff --git a/dap-tool-oth/src/main/resources/tool-schemas/claim-search-resource-output-schema.json b/dap-tool-oth/src/main/resources/tool-schemas/claim-search-resource-output-schema.json new file mode 100644 index 00000000..9d86a5b3 --- /dev/null +++ b/dap-tool-oth/src/main/resources/tool-schemas/claim-search-resource-output-schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "message": { "type": "string", "description": "Tool execution summary." }, + "request": { "type": "object", "description": "Normalized Tool request." } + }, + "required": ["message", "request"], + "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 3a39e872..24800021 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 @@ -43,6 +43,16 @@ class ClaimSearchRequestSchemaTest { 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")); + assertEquals(List.of("message", "request"), schema.get("required")); + } + @SuppressWarnings("unchecked") private Map> properties(Map schema) { return (Map>) schema.get("properties");