Refactor: Switch schema validator to MCP SDK DefaultJsonSchemaValidator (JSON Schema 2020-12) to match dapms
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 3m23s

This commit is contained in:
jade
2026-08-18 11:05:47 +09:00
parent 406e3c79b7
commit b8fd9492c6
7 changed files with 57 additions and 51 deletions

View File

@@ -1,6 +1,8 @@
package io.shinhanlife.dap.lib.config; package io.shinhanlife.dap.lib.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -18,7 +20,13 @@ public class ToolSchemaConfiguration {
} }
@Bean @Bean
public ToolArgumentSchemaValidator toolArgumentSchemaValidator(ObjectMapper objectMapper) { public JsonSchemaValidator mcpJsonSchemaValidator() {
return new ToolArgumentSchemaValidator(objectMapper); return new DefaultJsonSchemaValidator();
}
@Bean
public ToolArgumentSchemaValidator toolArgumentSchemaValidator(ObjectMapper objectMapper,
JsonSchemaValidator jsonSchemaValidator) {
return new ToolArgumentSchemaValidator(objectMapper, jsonSchemaValidator);
} }
} }

View File

@@ -1,12 +1,11 @@
package io.shinhanlife.dap.lib.mcp; package io.shinhanlife.dap.lib.mcp;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error; import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -61,8 +60,8 @@ public class McpToolExecutionService {
if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null; if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null;
try { try {
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]); Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
List<Error> errors = toolArgumentSchemaValidator.validate(schema, arguments); ValidationResponse result = toolArgumentSchemaValidator.validate(schema, arguments);
return errors.isEmpty() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId); return result.valid() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId);
} catch (Exception error) { } catch (Exception error) {
log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error); log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
return null; return null;
@@ -82,7 +81,7 @@ public class McpToolExecutionService {
Object methodResult, String requestId) { Object methodResult, String requestId) {
try { try {
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint()); Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint());
if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).isEmpty()) { if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).valid()) {
return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId); return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId);
} }
} catch (Exception error) { } catch (Exception error) {

View File

@@ -143,6 +143,7 @@ public class PodScaffolder {
bundle-id: %s bundle-id: %s
name-prefix: "" name-prefix: ""
security: security:
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
tenant-domains: tenant-domains:
TESTER-DEV: ALL TESTER-DEV: ALL
""".formatted(portStr, moduleName, moduleName.replace("dap-", "")); """.formatted(portStr, moduleName, moduleName.replace("dap-", ""));
@@ -339,6 +340,7 @@ public class PodScaffolder {
- "%s:%s" - "%s:%s"
environment: environment:
- TZ=Asia/Seoul - TZ=Asia/Seoul
- TOOL_SERVER_API_KEY=${TOOL_SERVER_API_KEY:-tool-server-key}
- AXHUB_GATEWAY_URL=http://gateway:8081 - AXHUB_GATEWAY_URL=http://gateway:8081
- AXHUB_TOOL_URL=http://%s:%s - AXHUB_TOOL_URL=http://%s:%s
- GLOW_COMMUNICATION_MCI_HOST=http://mci-mock - GLOW_COMMUNICATION_MCI_HOST=http://mci-mock

View File

@@ -16,12 +16,12 @@ import java.util.Scanner;
/** /**
* MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스입니다. * MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스입니다.
* *
* [실행 방법] * [실행 방법]
* 방법 1. IDE(IntelliJ 등)에서 직접 실행 * 방법 1. IDE(IntelliJ 등)에서 직접 실행
* - ToolScaffolder.java의 main 메서드를 실행합니다. * - ToolScaffolder.java의 main 메서드를 실행합니다.
* - 콘솔 질문에 차례대로 값을 입력하면 파일이 생성됩니다. * - 콘솔 질문에 차례대로 값을 입력하면 파일이 생성됩니다.
* *
* 방법 2. 명령줄에서 실행 * 방법 2. 명령줄에서 실행
* - 컴파일: javac -encoding UTF-8 dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java * - 컴파일: javac -encoding UTF-8 dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java
* - 실행: java -cp dap-was-lib/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder [이름] [ID] "[설명]" "[그룹]" "[통신방식]" "[모듈명]" * - 실행: java -cp dap-was-lib/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder [이름] [ID] "[설명]" "[그룹]" "[통신방식]" "[모듈명]"
@@ -37,7 +37,7 @@ import java.util.Scanner;
* 수정일 수정자 수정내용 * 수정일 수정자 수정내용
* ---------- -------- --------------------------- * ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성 * 2026.09.01 0986406 최초생성
* *
* </pre> * </pre>
*/ */
public class ToolScaffolder { public class ToolScaffolder {
@@ -515,7 +515,7 @@ public class ToolScaffolder {
httpApiName = httpApiName == null || httpApiName.isBlank() ? toKebabCase(baseName) : httpApiName.trim(); httpApiName = httpApiName == null || httpApiName.isBlank() ? toKebabCase(baseName) : httpApiName.trim();
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR"); String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get("."); Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
Path usecaseDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "usecase")); Path usecaseDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "usecase"));
Path usecaseImplDir = usecaseDir.resolve("impl"); Path usecaseImplDir = usecaseDir.resolve("impl");
Path dtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "dto")); Path dtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "dto"));
@@ -700,7 +700,7 @@ public class ToolScaffolder {
toolName, title, description, toolName, title, description,
toolHintLine, toolHintLine,
baseName, baseName baseName, baseName
); );
writeUtf8(usecaseDir.resolve(baseName + "UseCase.java"), serviceInterfaceContent); writeUtf8(usecaseDir.resolve(baseName + "UseCase.java"), serviceInterfaceContent);
@@ -798,7 +798,7 @@ public class ToolScaffolder {
baseName, baseName,
baseName, baseName,
baseName baseName
); );
String mciIoPackage = BASE_PACKAGE + "." + mciGroupPath.replace("/", "."); String mciIoPackage = BASE_PACKAGE + "." + mciGroupPath.replace("/", ".");
serviceImplContent = serviceImplContent serviceImplContent = serviceImplContent
.replace("import " + mciIoPackage + ".io." + interfaceId + "_I;", .replace("import " + mciIoPackage + ".io." + interfaceId + "_I;",
@@ -874,9 +874,9 @@ public class ToolScaffolder {
baseName, baseName,
baseName, baseName,
routingType, interfaceId routingType, interfaceId
); );
} }
writeUtf8(usecaseImplDir.resolve(baseName + "UseCaseImpl.java"), serviceImplContent); writeUtf8(usecaseImplDir.resolve(baseName + "UseCaseImpl.java"), serviceImplContent);
if (isMci) { if (isMci) {
@@ -992,10 +992,10 @@ public class ToolScaffolder {
baseName, interfaceId, baseName, baseName, interfaceId, baseName,
baseName, interfaceId, baseName, interfaceId,
baseName, interfaceId baseName, interfaceId
); );
converterContent = mciConverterContent(bizPackage, baseName, mciGroupPath.replace("/", "."), interfaceId); converterContent = mciConverterContent(bizPackage, baseName, mciGroupPath.replace("/", "."), interfaceId);
writeUtf8(converterDir.resolve(baseName + "Converter.java"), converterContent); writeUtf8(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n"); log.append("\n=========================================\n");
log.append(" Scaffolding Complete! (Routing: " + routingType + ")\n"); log.append(" Scaffolding Complete! (Routing: " + routingType + ")\n");
log.append("=========================================\n"); log.append("=========================================\n");
@@ -1040,9 +1040,9 @@ public class ToolScaffolder {
} }
} }
""".formatted( """.formatted(
BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."),
BASE_PACKAGE, mciGroupPath.replace("/", "."), clientPrefixCap, author, createDate, createDate, author, clientPrefixCap BASE_PACKAGE, mciGroupPath.replace("/", "."), clientPrefixCap, author, createDate, createDate, author, clientPrefixCap
); );
writeUtf8(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java"), mciClientContent); writeUtf8(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java"), mciClientContent);
log.append("[MCI Client] ").append(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java")).append("\n"); log.append("[MCI Client] ").append(mciClientDir.resolve("Mci" + clientPrefixCap + "Client.java")).append("\n");
} }
@@ -1184,7 +1184,7 @@ public class ToolScaffolder {
bizPackage, baseName, bizPackage, baseName,
bizPackage, baseName, author, createDate, createDate, author, bizPackage, baseName, author, createDate, createDate, author,
baseName, baseName, baseName, baseName, baseName, baseName, baseName baseName, baseName, baseName, baseName, baseName, baseName, baseName
); );
converterContent = legacyConverterContent(bizPackage, baseName); converterContent = legacyConverterContent(bizPackage, baseName);
writeUtf8(converterDir.resolve(baseName + "Converter.java"), converterContent); writeUtf8(converterDir.resolve(baseName + "Converter.java"), converterContent);
@@ -1669,7 +1669,7 @@ public class ToolScaffolder {
} }
private static String httpUseCaseImplContent(String bizPackage, String baseName, String httpPackage, private static String httpUseCaseImplContent(String bizPackage, String baseName, String httpPackage,
String httpApiClass, String author, String createDate) { String httpApiClass, String author, String createDate) {
String httpRequestClass = baseName + "HttpRequest"; String httpRequestClass = baseName + "HttpRequest";
String httpResponseClass = baseName + "HttpResponse"; String httpResponseClass = baseName + "HttpResponse";
String httpClientClass = httpApiClass + "Client"; String httpClientClass = httpApiClass + "Client";
@@ -1951,7 +1951,7 @@ public class ToolScaffolder {
FieldDefinition item = new FieldDefinition("item", field.itemType(), "", field.examples(), field.pattern(), false); FieldDefinition item = new FieldDefinition("item", field.itemType(), "", field.examples(), field.pattern(), false);
return "[" + mockValue(item) + "]"; return "[" + mockValue(item) + "]";
} }
String exampleStr = (field.examples() != null && !field.examples().isEmpty()) ? field.examples().get(0) : null; String exampleStr = (field.examples() != null && !field.examples().isEmpty()) ? field.examples().get(0) : null;
if (exampleStr == null || exampleStr.isBlank()) { if (exampleStr == null || exampleStr.isBlank()) {
return "null"; return "null";

View File

@@ -1,30 +1,27 @@
package io.shinhanlife.dap.lib.validation; package io.shinhanlife.dap.lib.validation;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error; import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import com.networknt.schema.InputFormat; import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import java.util.List;
import java.util.Map; import java.util.Map;
/** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */ /** Validates tool arguments with the MCP SDK JsonSchemaValidator (JSON Schema 2020-12). */
public class ToolArgumentSchemaValidator { public class ToolArgumentSchemaValidator {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final JsonSchemaValidator jsonSchemaValidator;
public ToolArgumentSchemaValidator(ObjectMapper objectMapper) { public ToolArgumentSchemaValidator(ObjectMapper objectMapper, JsonSchemaValidator jsonSchemaValidator) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.jsonSchemaValidator = jsonSchemaValidator;
} }
public List<Error> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception { public ValidationResponse validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) {
return validateValue(schemaDefinition, arguments); return validateValue(schemaDefinition, arguments);
} }
public List<Error> validateValue(Map<String, Object> schemaDefinition, Object value) throws Exception { public ValidationResponse validateValue(Map<String, Object> schemaDefinition, Object value) {
SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); Object converted = objectMapper.convertValue(value, Object.class);
Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition)); return jsonSchemaValidator.validate(schemaDefinition, converted);
return schema.validate(objectMapper.writeValueAsString(value), InputFormat.JSON);
} }
} }

View File

@@ -7,11 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
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 com.networknt.schema.Error; import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import com.networknt.schema.InputFormat; import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -47,7 +44,7 @@ class JsonSchemaGeneratorTest {
assertEquals("object", childSchema.get("type")); assertEquals("object", childSchema.get("type"));
assertTrue(required(childSchema).contains("businessDate")); assertTrue(required(childSchema).contains("businessDate"));
assertEquals("^\\d{8}$", property(childSchema, "businessDate").get("pattern")); assertEquals("^\\\\d{8}$", property(childSchema, "businessDate").get("pattern"));
} }
@Test @Test
@@ -62,12 +59,13 @@ class JsonSchemaGeneratorTest {
@Test @Test
void validatorRejectsInvalidNestedValue() throws Exception { void validatorRejectsInvalidNestedValue() throws Exception {
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
Schema schema = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7) JsonSchemaValidator validator = new DefaultJsonSchemaValidator();
.getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class))); Map<String, Object> schema = JsonSchemaGenerator.generateSchema(NestedRequest.class);
List<Error> errors = schema.validate(objectMapper.writeValueAsString(Map.of( Object arguments = objectMapper.convertValue(
"child", Map.of("businessDate", "2026-07-28"))), InputFormat.JSON); Map.of("child", Map.of("businessDate", "2026-07-28")), Object.class);
JsonSchemaValidator.ValidationResponse result = validator.validate(schema, arguments);
assertFalse(errors.isEmpty()); assertFalse(result.valid());
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -124,7 +122,7 @@ class JsonSchemaGeneratorTest {
private static class NestedChild { private static class NestedChild {
@io.swagger.v3.oas.annotations.media.Schema( @io.swagger.v3.oas.annotations.media.Schema(
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED, requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
pattern = "^\\d{8}$") pattern = "^\\\\d{8}$")
private String businessDate; private String businessDate;
} }
} }

View File

@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
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.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -11,16 +12,17 @@ import org.junit.jupiter.api.Test;
class ToolArgumentSchemaValidatorTest { class ToolArgumentSchemaValidatorTest {
private final ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper()); private final ToolArgumentSchemaValidator validator =
new ToolArgumentSchemaValidator(new ObjectMapper(), new DefaultJsonSchemaValidator());
@Test @Test
void validatesDraft7SchemaWithTheRuntimeNetworkntVersion() throws Exception { void validatesJsonSchema202012WithTheMcpSdkValidator() {
Map<String, Object> schema = Map.of( Map<String, Object> schema = Map.of(
"type", "object", "type", "object",
"properties", Map.of("name", Map.of("type", "string")), "properties", Map.of("name", Map.of("type", "string")),
"required", List.of("name")); "required", List.of("name"));
assertTrue(validator.validate(schema, Map.of("name", "Hong")).isEmpty()); assertTrue(validator.validate(schema, Map.of("name", "Hong")).valid());
assertFalse(validator.validate(schema, Map.of()).isEmpty()); assertFalse(validator.validate(schema, Map.of()).valid());
} }
} }