feat: support MCP output schemas
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s

This commit is contained in:
jade
2026-08-04 11:24:59 +09:00
parent 15262d5a0f
commit 31e6fd605d
10 changed files with 192 additions and 5 deletions

View File

@@ -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 시 라우팅은 되나 목록에서 숨김)

View File

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

View File

@@ -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<String, Object> 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<String, Object> resolveOutput(McpFunction function) {
return resolveOutput(function, null);
}
private Map<String, Object> 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);
}
}

View File

@@ -192,6 +192,22 @@ public class BusinessToolController {
} else {
methodResult = targetMethod.invoke(targetBean, invokeArgument);
}
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType());
if (!outputSchema.isEmpty()) {
List<Error> outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult);
if (!outputErrors.isEmpty()) {
log.error("[Tool] Output schema validation failed. tool={}, errors={}",
functionName, outputErrors);
Map<String, Object> 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;

View File

@@ -21,8 +21,12 @@ public class ToolArgumentSchemaValidator {
}
public List<Error> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception {
return validateValue(schemaDefinition, arguments);
}
public List<Error> validateValue(Map<String, Object> 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);
}
}

View File

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