feat: opt in to output schema validation
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m41s

This commit is contained in:
jade
2026-08-10 09:24:05 +09:00
parent 6809435cc2
commit 7819b11ce3
4 changed files with 40 additions and 41 deletions

View File

@@ -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 {
}

View File

@@ -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()) {

View File

@@ -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<String, Object> resolve(org.springaicommunity.mcp.annotation.McpTool function, ToolHint hint, Class<?> requestType) {
public Map<String, Object> 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<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, Class<?> responseType, ToolHint hint) {
public Map<String, Object> 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<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, Class<?> responseType) {
// Object, Map 등 구체적인 DTO가 아닌 경우 검증 스킵
public Map<String, Object> 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<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function) {
return resolveOutput(function, null);
return Map.of();
}
private Map<String, Object> loadResource(String location) {
@@ -76,12 +75,4 @@ public class ToolSchemaResolver {
throw new IllegalStateException("Failed to load MCP schema resource: " + location, e);
}
}
private Map<String, Object> 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);
}
}
}
}

View File

@@ -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<String, Object> 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<String, Object> schema = resolver.resolveOutput(
method.getAnnotation(McpTool.class), AutomaticRequest.class);
assertTrue(schema.isEmpty());
}
@SuppressWarnings("unchecked")
private Map<String, Object> properties(Map<String, Object> schema) {
return (Map<String, Object>) 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;
}
}
}