feat: opt in to output schema validation
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m41s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m41s
This commit is contained in:
@@ -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 {
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user