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")) {
|
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()) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,19 +69,15 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user