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")) { if (!schemaAnnotation.description().isEmpty() && !fieldSchema.containsKey("description")) {
fieldSchema.put("description", schemaAnnotation.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()); requiredList.add(field.getName());
} }
if (!schemaAnnotation.pattern().isEmpty()) { 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.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; 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.io.InputStream;
import java.util.Map; import java.util.Map;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import io.shinhanlife.dap.lib.annotation.ToolHint; /** Resolves MCP Tool schemas from resources or DTO metadata. */
/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */
public class ToolSchemaResolver { public class ToolSchemaResolver {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -18,7 +17,8 @@ public class ToolSchemaResolver {
this.objectMapper = objectMapper; 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()) { if (hint != null && !hint.inputSchemaResource().isBlank()) {
return loadResource(hint.inputSchemaResource()); return loadResource(hint.inputSchemaResource());
} }
@@ -26,12 +26,11 @@ public class ToolSchemaResolver {
} }
/** /**
* Resolves an explicitly declared response schema. * Resolves a response schema only when it is explicitly declared.
* Response schemas are opt-in so existing tools keep their current response behavior. * A JSON resource has precedence over a DTO marker annotation.
* ToolHint.outputSchemaResource()가 있으면 classpath JSON 파일에서 로드하고,
* 없으면 responseType DTO를 분석하여 자동 생성합니다.
*/ */
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()) { if (hint != null && !hint.outputSchemaResource().isBlank()) {
return loadResource(hint.outputSchemaResource()); return loadResource(hint.outputSchemaResource());
} }
@@ -39,16 +38,16 @@ public class ToolSchemaResolver {
} }
/** /**
* Resolves an explicitly declared response schema. * Generates a response schema only for DTOs marked with {@link McpOutputSchema}.
* Response schemas are opt-in so existing tools keep their current response behavior.
*/ */
public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function, Class<?> responseType) { public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function,
// Object, Map 등 구체적인 DTO가 아닌 경우 검증 스킵 Class<?> responseType) {
if (responseType == null if (responseType == null
|| responseType == Object.class || responseType == Object.class
|| Map.class.isAssignableFrom(responseType) || Map.class.isAssignableFrom(responseType)
|| responseType == Void.class || responseType == Void.class
|| responseType == void.class) { || responseType == void.class
|| !responseType.isAnnotationPresent(McpOutputSchema.class)) {
return Map.of(); return Map.of();
} }
return JsonSchemaGenerator.generateSchema(responseType); return JsonSchemaGenerator.generateSchema(responseType);
@@ -58,7 +57,7 @@ public class ToolSchemaResolver {
* Retained for callers that use only explicit output schemas. * Retained for callers that use only explicit output schemas.
*/ */
public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function) { 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) { 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); 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 static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; 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.lang.reflect.Method;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -23,14 +25,6 @@ class ToolSchemaResolverTest {
assertTrue(properties(schema).containsKey("differentField")); 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 @Test
void generatesOutputSchemaFromMarkedResponseDto() throws Exception { void generatesOutputSchemaFromMarkedResponseDto() throws Exception {
Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); Method method = AutomaticOutputSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
@@ -45,10 +39,13 @@ class ToolSchemaResolverTest {
@Test @Test
void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception { void doesNotEnableOutputValidationWhenOutputSchemaIsNotDeclared() throws Exception {
Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class); Method method = AutomaticSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
Map<String, Object> schema = resolver.resolveOutput( Map<String, Object> schema = resolver.resolveOutput(
method.getAnnotation(McpTool.class), AutomaticRequest.class); method.getAnnotation(McpTool.class), AutomaticRequest.class);
assertTrue(schema.isEmpty()); assertTrue(schema.isEmpty());
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Map<String, Object> properties(Map<String, Object> schema) { private Map<String, Object> properties(Map<String, Object> schema) {
return (Map<String, Object>) schema.get("properties"); return (Map<String, Object>) schema.get("properties");
@@ -72,18 +69,14 @@ class ToolSchemaResolverTest {
} }
} }
@McpOutputSchema
static class SimpleResponse { static class SimpleResponse {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"SUCCESS", "FAILURE"})
private String resultCode; private String resultCode;
private String message; private String message;
} }
static class OutputSchemaTool {
@McpTool(name = "oth.test.explicit.search")
void search(AutomaticRequest request) {
}
}
static class AutomaticRequest { static class AutomaticRequest {
private String differentField; private String differentField;
} }