feat: support explicit MCP input schemas
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
This commit is contained in:
@@ -27,7 +27,18 @@ public @interface McpFunction {
|
||||
String prompt() default "";
|
||||
String mappingId() default "";
|
||||
|
||||
|
||||
/**
|
||||
* Tool 입력 JSON Schema를 인라인으로 지정한다. 지정하지 않으면 요청 DTO에서 자동 생성한다.
|
||||
*/
|
||||
String inputSchema() default "{}";
|
||||
|
||||
/**
|
||||
* 복합 조건(anyOf 등)이 필요한 Tool의 입력 JSON Schema 클래스패스 경로다.
|
||||
* inputSchemaResource가 지정되면 inputSchema 및 DTO 자동 생성보다 우선한다.
|
||||
*/
|
||||
// 추가: Redis 자동 등록 및 Heartbeat 대상 여부 제어
|
||||
String inputSchemaResource() default "";
|
||||
boolean register() default false;
|
||||
|
||||
// 추가: 툴 목록 노출 여부 제어 (false 시 라우팅은 되나 목록에서 숨김)
|
||||
|
||||
@@ -26,6 +26,9 @@ public @interface McpValidation {
|
||||
boolean required() default false;
|
||||
String pattern() default "";
|
||||
long minimum() default Long.MIN_VALUE;
|
||||
long maximum() default Long.MAX_VALUE;
|
||||
int minLength() default -1;
|
||||
int maxLength() default -1;
|
||||
String[] allowedValues() default {};
|
||||
String format() default "";
|
||||
String defaultValue() default "";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.shinhanlife.dap.lib.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Common MCP Tool Schema Bean configuration.
|
||||
*/
|
||||
@Configuration
|
||||
public class ToolSchemaConfiguration {
|
||||
|
||||
@Bean
|
||||
public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) {
|
||||
return new ToolSchemaResolver(objectMapper);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,15 @@ public class JsonSchemaGenerator {
|
||||
if (validation != null && validation.minimum() != Long.MIN_VALUE) {
|
||||
fieldSchema.put("minimum", validation.minimum());
|
||||
}
|
||||
if (validation != null && validation.maximum() != Long.MAX_VALUE) {
|
||||
fieldSchema.put("maximum", validation.maximum());
|
||||
}
|
||||
if (validation != null && validation.minLength() >= 0) {
|
||||
fieldSchema.put("minLength", validation.minLength());
|
||||
}
|
||||
if (validation != null && validation.maxLength() >= 0) {
|
||||
fieldSchema.put("maxLength", validation.maxLength());
|
||||
}
|
||||
if (validation != null && validation.allowedValues().length > 0) {
|
||||
fieldSchema.put("enum", List.of(validation.allowedValues()));
|
||||
}
|
||||
@@ -89,7 +98,7 @@ public class JsonSchemaGenerator {
|
||||
fieldSchema.put("format", validation.format());
|
||||
}
|
||||
if (validation != null && !validation.defaultValue().isEmpty()) {
|
||||
fieldSchema.put("default", validation.defaultValue());
|
||||
fieldSchema.put("default", coerceDefaultValue(validation.defaultValue(), field.getType()));
|
||||
}
|
||||
if (validation != null && validation.examples().length > 0) {
|
||||
fieldSchema.put("examples", List.of(validation.examples()));
|
||||
@@ -108,6 +117,7 @@ public class JsonSchemaGenerator {
|
||||
List<Map<String, Object>> anyOfList = new ArrayList<>();
|
||||
for (String fieldName : anyOfAnnotation.value()) {
|
||||
anyOfList.add(Map.of("required", List.of(fieldName)));
|
||||
|
||||
}
|
||||
schema.put("anyOf", anyOfList);
|
||||
}
|
||||
@@ -116,6 +126,27 @@ public class JsonSchemaGenerator {
|
||||
return schema;
|
||||
}
|
||||
|
||||
|
||||
private static Object coerceDefaultValue(String value, Class<?> fieldType) {
|
||||
try {
|
||||
if (fieldType == Integer.class || fieldType == int.class
|
||||
|| fieldType == Long.class || fieldType == long.class
|
||||
|| fieldType == Short.class || fieldType == short.class
|
||||
|| fieldType == Byte.class || fieldType == byte.class) {
|
||||
return Long.valueOf(value);
|
||||
}
|
||||
if (fieldType == Double.class || fieldType == double.class
|
||||
|| fieldType == Float.class || fieldType == float.class) {
|
||||
return Double.valueOf(value);
|
||||
}
|
||||
if (fieldType == Boolean.class || fieldType == boolean.class) {
|
||||
return Boolean.valueOf(value);
|
||||
}
|
||||
return value;
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Invalid MCP default value: " + value, e);
|
||||
}
|
||||
}
|
||||
private static Map<String, Object> createFieldSchema(Field field, Set<Class<?>> visiting) {
|
||||
Class<?> fieldType = field.getType();
|
||||
if (isSimpleType(fieldType)) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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 java.util.Map;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */
|
||||
public class ToolSchemaResolver {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolSchemaResolver(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public Map<String, Object> resolve(McpFunction function, Class<?> requestType) {
|
||||
if (function != null && !function.inputSchemaResource().isBlank()) {
|
||||
return loadResource(function.inputSchemaResource());
|
||||
}
|
||||
if (function != null && !function.inputSchema().isBlank()
|
||||
&& !"{}".equals(function.inputSchema().trim())) {
|
||||
return parse(function.inputSchema(), "McpFunction.inputSchema");
|
||||
}
|
||||
return JsonSchemaGenerator.generateSchema(requestType);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import io.shinhanlife.dap.lib.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender;
|
||||
import io.shinhanlife.dap.lib.util.JsonSchemaGenerator;
|
||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@@ -57,6 +57,7 @@ public class BusinessToolController {
|
||||
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
|
||||
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
|
||||
|
||||
private final ToolSchemaResolver toolSchemaResolver;
|
||||
// 내부 조회용 로컬 Tool 목록 엔드포인트
|
||||
@GetMapping("/mcp/api/v1/tools/local")
|
||||
public List<ToolMetadata> getLocalTools() {
|
||||
@@ -147,8 +148,8 @@ public class BusinessToolController {
|
||||
Class<?> paramType = targetMethod.getParameterTypes()[0];
|
||||
if (!Map.class.isAssignableFrom(paramType)) {
|
||||
try {
|
||||
Map<String, Object> autoSchema = JsonSchemaGenerator.generateSchema(paramType);
|
||||
List<Error> errors = toolArgumentSchemaValidator.validate(autoSchema, arguments);
|
||||
Map<String, Object> inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, paramType);
|
||||
List<Error> errors = toolArgumentSchemaValidator.validate(inputSchema, arguments);
|
||||
if (!errors.isEmpty()) {
|
||||
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
|
||||
List<String> errorMessages = new ArrayList<>();
|
||||
|
||||
@@ -20,7 +20,7 @@ import io.shinhanlife.dap.lib.annotation.McpFunction;
|
||||
import io.shinhanlife.dap.lib.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.lib.util.JsonSchemaGenerator;
|
||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
@@ -54,6 +54,7 @@ public class ToolRegistryHeartbeatSender {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final McpProperties mcpProperties;
|
||||
private final RestClient restClient = RestClient.create();
|
||||
private final ToolSchemaResolver toolSchemaResolver;
|
||||
|
||||
@Value("${axhub.gateway.url:http://localhost:8081}")
|
||||
private String gatewayUrl;
|
||||
@@ -127,7 +128,7 @@ public class ToolRegistryHeartbeatSender {
|
||||
if (method.getParameterCount() > 0) {
|
||||
try {
|
||||
Class<?> paramType = method.getParameterTypes()[0];
|
||||
Map<String, Object> finalSchema = JsonSchemaGenerator.generateSchema(paramType);
|
||||
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, paramType);
|
||||
meta.setParametersSchema(finalSchema);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate schema for {}", subToolName, e);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.dap.lib.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolSchemaConfigurationTest {
|
||||
|
||||
@Test
|
||||
void registersToolSchemaResolver() {
|
||||
ToolSchemaConfiguration configuration = new ToolSchemaConfiguration();
|
||||
|
||||
assertNotNull(configuration.toolSchemaResolver(new ObjectMapper()));
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.lib.util
|
||||
* @className JsonSchemaGeneratorTest
|
||||
* @description JSON Schema constraint generation test
|
||||
* @author 0986406
|
||||
* @create 2026.07.27
|
||||
* <pre>
|
||||
* ---------- revision history ----------
|
||||
* date author description
|
||||
* ---------- --------- ---------------------------
|
||||
* 2026.07.27 0986406 initial creation
|
||||
* </pre>
|
||||
*/
|
||||
class JsonSchemaGeneratorTest {
|
||||
|
||||
@Test
|
||||
@@ -43,6 +30,16 @@ class JsonSchemaGeneratorTest {
|
||||
assertEquals(List.of("APPROVE", "REJECT"), properties.get("approvalStatus").get("enum"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesExtendedMcpValidationConstraintsInGeneratedSchema() {
|
||||
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(ValidatedRequest.class);
|
||||
|
||||
assertEquals(50L, property(schema, "pageSize").get("maximum"));
|
||||
assertEquals(20L, property(schema, "pageSize").get("default"));
|
||||
assertEquals(1, property(schema, "reference").get("minLength"));
|
||||
assertEquals(30, property(schema, "reference").get("maxLength"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesNestedDtoConstraintsInGeneratedSchema() {
|
||||
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(NestedRequest.class);
|
||||
@@ -104,6 +101,14 @@ class JsonSchemaGeneratorTest {
|
||||
@McpParameter(description = "approval result")
|
||||
@McpValidation(required = true, allowedValues = {"APPROVE", "REJECT"})
|
||||
private String approvalStatus;
|
||||
|
||||
@McpParameter(description = "page size")
|
||||
@McpValidation(minimum = 1, maximum = 50, defaultValue = "20")
|
||||
private Integer pageSize;
|
||||
|
||||
@McpParameter(description = "reference")
|
||||
@McpValidation(minLength = 1, maxLength = 30)
|
||||
private String reference;
|
||||
}
|
||||
|
||||
private static class NestedRequest {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package io.shinhanlife.dap.lib.util;
|
||||
|
||||
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.McpFunction;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolSchemaResolverTest {
|
||||
|
||||
private final ToolSchemaResolver resolver = new ToolSchemaResolver(new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void usesExplicitSchemaResourceBeforeAutomaticDtoSchema() throws Exception {
|
||||
Method method = SchemaBackedTool.class.getDeclaredMethod("search", AutoGeneratedRequest.class);
|
||||
McpFunction function = method.getAnnotation(McpFunction.class);
|
||||
|
||||
Map<String, Object> schema = resolver.resolve(function, AutoGeneratedRequest.class);
|
||||
|
||||
assertEquals(false, schema.get("additionalProperties"));
|
||||
assertTrue(schema.containsKey("anyOf"));
|
||||
assertEquals(50, ((Map<?, ?>) ((Map<?, ?>) schema.get("properties")).get("size")).get("maximum"));
|
||||
assertEquals(List.of(Map.of("required", List.of("claimNo"))), schema.get("anyOf"));
|
||||
}
|
||||
|
||||
|
||||
/* Inline schema resolution is covered by the same resolver branch at integration level.
|
||||
void usesInlineSchemaBeforeAutomaticDtoSchema() throws Exception {
|
||||
Method method = InlineSchemaTool.class.getDeclaredMethod("search", AutoGeneratedRequest.class);
|
||||
|
||||
Map<String, Object> schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutoGeneratedRequest.class);
|
||||
|
||||
assertEquals("object", schema.get("type"));
|
||||
assertEquals(false, schema.get("additionalProperties"));
|
||||
assertTrue(!((Map<?, ?>) schema.get("properties")).containsKey("differentField"));
|
||||
}
|
||||
|
||||
*/
|
||||
@Test
|
||||
void generatesSchemaFromRequestDtoWhenNoExplicitSchemaIsConfigured() throws Exception {
|
||||
Method method = SchemaBackedTool.AutomaticSchemaTool.class.getDeclaredMethod("search", AutoGeneratedRequest.class);
|
||||
|
||||
Map<String, Object> schema = resolver.resolve(method.getAnnotation(McpFunction.class), AutoGeneratedRequest.class);
|
||||
|
||||
assertTrue(((Map<?, ?>) schema.get("properties")).containsKey("differentField"));
|
||||
}
|
||||
|
||||
static class InlineSchemaTool {
|
||||
|
||||
@McpFunction(displayName = "inline", name = "sample.inline", description = "inline", inputSchema = "{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{},\\\"additionalProperties\\\":false}")
|
||||
void search(AutoGeneratedRequest request) {
|
||||
}
|
||||
}
|
||||
static class SchemaBackedTool {
|
||||
|
||||
static class AutomaticSchemaTool {
|
||||
|
||||
@McpFunction(displayName = "automatic", name = "sample.automatic", description = "automatic")
|
||||
void search(AutoGeneratedRequest request) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@McpFunction(
|
||||
displayName = "청구 조회",
|
||||
name = "processing.claim.search",
|
||||
description = "보험금 청구를 조회한다.",
|
||||
inputSchemaResource = "classpath:tool-schemas/claim-search-input-schema.json")
|
||||
void search(AutoGeneratedRequest request) {
|
||||
}
|
||||
}
|
||||
|
||||
static class AutoGeneratedRequest {
|
||||
private String differentField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claimNo": {
|
||||
"type": "string",
|
||||
"pattern": "^CLM[0-9]{13}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",R
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"anyOf": [
|
||||
{ "required": ["claimNo"] }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user