feat: support MCP output schemas
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s
This commit is contained in:
@@ -39,6 +39,17 @@ public @interface McpFunction {
|
|||||||
*/
|
*/
|
||||||
// 추가: Redis 자동 등록 및 Heartbeat 대상 여부 제어
|
// 추가: Redis 자동 등록 및 Heartbeat 대상 여부 제어
|
||||||
String inputSchemaResource() default "";
|
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;
|
boolean register() default false;
|
||||||
|
|
||||||
// 추가: 툴 목록 노출 여부 제어 (false 시 라우팅은 되나 목록에서 숨김)
|
// 추가: 툴 목록 노출 여부 제어 (false 시 라우팅은 되나 목록에서 숨김)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ 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;
|
||||||
import io.shinhanlife.dap.lib.annotation.McpFunction;
|
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 java.util.Map;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
@@ -27,19 +27,44 @@ public class ToolSchemaResolver {
|
|||||||
return JsonSchemaGenerator.generateSchema(requestType);
|
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) {
|
private Map<String, Object> loadResource(String location) {
|
||||||
String path = location.startsWith("classpath:")
|
String path = location.startsWith("classpath:")
|
||||||
? location.substring("classpath:".length())
|
? location.substring("classpath:".length())
|
||||||
: location;
|
: location;
|
||||||
ClassPathResource resource = new ClassPathResource(path);
|
ClassPathResource resource = new ClassPathResource(path);
|
||||||
if (!resource.exists()) {
|
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()) {
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
return objectMapper.readValue(inputStream, new TypeReference<>() { });
|
return objectMapper.readValue(inputStream, new TypeReference<>() { });
|
||||||
} catch (Exception e) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,22 @@ public class BusinessToolController {
|
|||||||
methodResult = targetMethod.invoke(targetBean, invokeArgument);
|
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;
|
long elapsed = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
// 5. 결과 반환 (순수 REST 응답)
|
// 5. 결과 반환 (순수 REST 응답)
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ public class ToolArgumentSchemaValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Error> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception {
|
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);
|
SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
|
||||||
Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition));
|
Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition));
|
||||||
return schema.validate(objectMapper.writeValueAsString(arguments), InputFormat.JSON);
|
return schema.validate(objectMapper.writeValueAsString(value), InputFormat.JSON);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ 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.McpFunction;
|
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.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -32,11 +35,42 @@ 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(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")
|
@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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> property(Map<String, Object> schema, String name) {
|
||||||
|
return (Map<String, Object>) properties(schema).get(name);
|
||||||
|
}
|
||||||
|
|
||||||
static class InlineSchemaTool {
|
static class InlineSchemaTool {
|
||||||
@McpFunction(
|
@McpFunction(
|
||||||
displayName = "inline",
|
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 {
|
static class AutomaticRequest {
|
||||||
private String differentField;
|
private String differentField;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||||
|
|
||||||
|
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
|
||||||
|
import io.shinhanlife.dap.lib.annotation.McpParameter;
|
||||||
|
import io.shinhanlife.dap.lib.annotation.McpValidation;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple response DTO sample that generates an MCP output schema from annotations.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@McpOutputSchema
|
||||||
|
public class ClaimSearchResponse {
|
||||||
|
|
||||||
|
@McpParameter(description = "Tool execution result code.")
|
||||||
|
@McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"})
|
||||||
|
private String resultCode;
|
||||||
|
|
||||||
|
@McpParameter(description = "User-readable execution message.")
|
||||||
|
@McpValidation(required = true, maxLength = 200)
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@McpParameter(description = "Number of matched claims.")
|
||||||
|
@McpValidation(minimum = 0)
|
||||||
|
private Integer totalCount;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ public interface ClaimSearchSchemaSampleUseCase {
|
|||||||
description = "inputSchemaResource를 사용하는 청구 조회 Tool 샘플입니다.",
|
description = "inputSchemaResource를 사용하는 청구 조회 Tool 샘플입니다.",
|
||||||
prompt = "청구번호 또는 계약번호로 보험금 청구를 조회해줘.",
|
prompt = "청구번호 또는 계약번호로 보험금 청구를 조회해줘.",
|
||||||
inputSchemaResource = "classpath:tool-schemas/claim-search-resource-input-schema.json",
|
inputSchemaResource = "classpath:tool-schemas/claim-search-resource-input-schema.json",
|
||||||
readOnlyHint = true,
|
outputSchemaResource = "classpath:tool-schemas/claim-search-resource-output-schema.json", readOnlyHint = true,
|
||||||
idempotentHint = true
|
idempotentHint = true
|
||||||
)
|
)
|
||||||
Object search(ClaimSearchRequest request);
|
Object search(ClaimSearchRequest request);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": { "type": "string", "description": "Tool execution summary." },
|
||||||
|
"request": { "type": "object", "description": "Normalized Tool request." }
|
||||||
|
},
|
||||||
|
"required": ["message", "request"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -43,6 +43,16 @@ class ClaimSearchRequestSchemaTest {
|
|||||||
assertEquals(50, property(schema, "size").get("maximum"));
|
assertEquals(50, property(schema, "size").get("maximum"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesOutputSchemaFromToolModuleResource() throws Exception {
|
||||||
|
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||||
|
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||||
|
McpFunction function = method.getAnnotation(McpFunction.class);
|
||||||
|
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper()).resolveOutput(function);
|
||||||
|
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
||||||
|
assertEquals(List.of("message", "request"), schema.get("required"));
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
|
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
|
||||||
return (Map<String, Map<String, Object>>) schema.get("properties");
|
return (Map<String, Map<String, Object>>) schema.get("properties");
|
||||||
|
|||||||
Reference in New Issue
Block a user