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

This commit is contained in:
jade
2026-08-04 10:50:25 +09:00
parent 52f585fa45
commit 2957272cc3
16 changed files with 429 additions and 19 deletions

View File

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

View File

@@ -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 "";

View File

@@ -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);
}
}

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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<>();

View File

@@ -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);

View File

@@ -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()));
}
}

View File

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

View File

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

View File

@@ -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"] }
]
}

View File

@@ -0,0 +1,57 @@
package io.shinhanlife.dap.mcc.biz.cmm.dto;
import io.shinhanlife.dap.lib.annotation.McpAnyOf;
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;
/**
* 보험금 청구 조회 Tool의 입력 DTO 샘플이다.
* 청구번호 또는 계약번호 중 하나를 반드시 입력받는다.
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@McpAnyOf({"claimNo", "contractNo"})
public class ClaimSearchRequest {
@McpParameter(description = "청구번호. CLM 다음 숫자 13자리 형식이다.")
@McpValidation(pattern = "^CLM[0-9]{13}$", examples = {"CLM2026070100123"})
private String claimNo;
@McpParameter(description = "계약번호. 숫자 11자리 형식이다.")
@McpValidation(pattern = "^[0-9]{11}$", examples = {"10023456789"})
private String contractNo;
@McpParameter(description = "청구 상태 필터")
@McpValidation(allowedValues = {
"RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED",
"APPROVED", "PAID", "REJECTED", "WITHDRAWN"
})
private String status;
@McpParameter(description = "청구 유형 필터")
@McpValidation(allowedValues = {
"MEDICAL", "SURGERY", "HOSPITALIZATION",
"DIAGNOSIS", "DEATH", "DISABILITY"
})
private String claimType;
@McpParameter(description = "접수일 조회 시작일(YYYY-MM-DD)")
@McpValidation(format = "date", examples = {"2026-01-01"})
private String fromDate;
@McpParameter(description = "접수일 조회 종료일(YYYY-MM-DD)")
@McpValidation(format = "date", examples = {"2026-07-31"})
private String toDate;
@McpParameter(description = "반환할 최대 건수")
@McpValidation(minimum = 1, maximum = 50, defaultValue = "20")
private Integer size;
}

View File

@@ -0,0 +1,24 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest;
@McpTool(
routingType = "DIRECT",
categoryKey = "cmm"
)
public interface ClaimSearchSchemaSampleUseCase {
@McpFunction(
register = false,
displayName = "청구 조회 JSON Schema 샘플",
name = "sample.claim.search.resource",
description = "inputSchemaResource를 사용하는 청구 조회 Tool 샘플입니다.",
prompt = "청구번호 또는 계약번호로 보험금 청구를 조회해줘.",
inputSchemaResource = "classpath:tool-schemas/claim-search-resource-input-schema.json",
readOnlyHint = true,
idempotentHint = true
)
Object search(ClaimSearchRequest request);
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase.impl;
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest;
import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase;
import java.util.Map;
import org.springframework.stereotype.Service;
/**
* inputSchemaResource 적용 방식을 보여주는 비노출 샘플 Tool이다.
* 실제 MCI/EIMS 연동은 추가하지 않는다.
*/
@Service
public class ClaimSearchSchemaSampleUseCaseImpl implements ClaimSearchSchemaSampleUseCase {
@Override
public Object search(ClaimSearchRequest request) {
return Map.of(
"message", "inputSchemaResource JSON Schema sample",
"request", request == null ? Map.of() : request
);
}
}

View File

@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"claimNo": {
"type": "string",
"description": "청구번호. CLM 다음 숫자 13자리 형식이다.",
"pattern": "^CLM[0-9]{13}$",
"examples": ["CLM2026070100123"]
},
"contractNo": {
"type": "string",
"description": "계약번호. 숫자 11자리 형식이다.",
"pattern": "^[0-9]{11}$",
"examples": ["10023456789"]
},
"status": {
"type": "string",
"enum": [
"RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED",
"APPROVED", "PAID", "REJECTED", "WITHDRAWN"
]
},
"size": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 20
}
},
"required": [],
"additionalProperties": false,
"anyOf": [
{ "required": ["claimNo"] },
{ "required": ["contractNo"] }
]
}

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.mcc.biz.cmm.dto;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.shinhanlife.dap.lib.util.JsonSchemaGenerator;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ClaimSearchRequestSchemaTest {
@Test
void generatesClaimOrContractSearchSchema() {
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(ClaimSearchRequest.class);
Map<String, Map<String, Object>> properties = properties(schema);
assertEquals(false, schema.get("additionalProperties"));
assertEquals("^CLM[0-9]{13}$", properties.get("claimNo").get("pattern"));
assertEquals(50L, properties.get("size").get("maximum"));
assertEquals(20L, properties.get("size").get("default"));
assertEquals(List.of(
Map.of("required", List.of("claimNo")),
Map.of("required", List.of("contractNo"))), schema.get("anyOf"));
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
return (Map<String, Map<String, Object>>) schema.get("properties");
}
}