Fix scaffold errors and apply ToolHint annotations
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 33s

This commit is contained in:
jade
2026-08-06 01:14:53 +09:00
parent 6394ac651b
commit 7287a96a01
75 changed files with 974 additions and 898 deletions

View File

@@ -1,19 +0,0 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* MCP 스키마 생성 시 anyOf (해당 필드들 중 최소 1개 이상 필수) 제약을 부여합니다.
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface McpAnyOf {
/**
* anyOf 제약에 포함될 필드명 목록
* 예: @McpAnyOf({"claimNo", "contractNo"})
*/
String[] value();
}

View File

@@ -1,76 +0,0 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpFunction
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpFunction {
String displayName(); // 사람이 읽는 라벨 (예: "고객 조회 툴")
String name(); // MCP 서브툴 명칭 (예: "customer_search")
String description();
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 "";
/**
* 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 시 라우팅은 되나 목록에서 숨김)
boolean visible() default true;
// 추가: HITL 승인 체계 지원 (실행 전 사용자 승인 필요 여부)
boolean requiresApproval() default false;
boolean readOnlyHint() default false;
boolean destructiveHint() default false;
boolean idempotentHint() default false;
boolean openWorldHint() default false;
/** Version exposed as _meta.version in the Tool Manifest. */
String version() default "1.0.0";
/** Maximum execution time exposed as _meta.timeoutMillis in the Tool Manifest. */
long timeoutMillis() default 300000L;
/** Whether the Tool is available for MCP exposure. */
boolean enabled() default true;
// 추가: 툴 별 기본 Timeout 설정 (기본 300초 = 300000ms)
}

View File

@@ -1,17 +0,0 @@
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

@@ -1,26 +0,0 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpParameter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpParameter {
String description();
boolean required() default false;
}

View File

@@ -1,33 +0,0 @@
package io.shinhanlife.dap.lib.annotation;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpTool
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface McpTool {
@AliasFor(annotation = Component.class)
String value() default "";
String categoryKey() default "common";
String routingType() default "HTTP";
}

View File

@@ -1,36 +0,0 @@
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;
/**
* @package io.shinhanlife.dap.lib.annotation
* @className McpValidation
* @description Declares JSON Schema validation constraints for MCP tool input fields
* @author 0986406
* @create 2026.07.27
* <pre>
* ---------- revision history ----------
* date author description
* ---------- --------- ---------------------------
* 2026.07.27 0986406 initial creation
* </pre>
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
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 "";
boolean nullable() default false; String defaultValue() default "";
String[] examples() default {};
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Spring AI @Tool 어노테이션을 보완하여 MCP 시스템 메타데이터를 추가 제공하는 힌트 어노테이션
* @package io.shinhanlife.dap.lib.annotation
* @className ToolHint
* @description 비즈니스 로직(Tool)과 시스템 제어 메타데이터 분리
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ToolHint {
boolean register() default false;
boolean requiresApproval() default false;
String categoryKey() default "com";
String mappingId() default "";
String inputSchemaResource() default "";
String outputSchemaResource() default "";
}

View File

@@ -15,7 +15,6 @@ package io.shinhanlife.dap.lib.aop;
*
* </pre>
*/
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.config.McpProperties;
import java.lang.reflect.Method;
import lombok.RequiredArgsConstructor;
@@ -24,6 +23,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@@ -35,12 +35,12 @@ public class ToolSlaMonitoringAspect {
private final McpProperties mcpProperties;
// @McpFunction 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다.
@Around("@annotation(McpFunction)")
// @McpTool 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다.
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
public Object monitorToolSla(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
McpFunction functionAnnotation = method.getAnnotation(McpFunction.class);
McpTool functionAnnotation = method.getAnnotation(McpTool.class);
// 네임스페이스 자동 주입 로직을 반영하여 최종 툴 이름을 산출합니다.
String baseName = functionAnnotation.name();

View File

@@ -5,11 +5,21 @@ import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
/** Exposes every Tool Pod through the MCP Streamable HTTP transport. */
@Slf4j
@Configuration
public class ToolMcpServerConfiguration {
@PostConstruct
public void init() {
log.warn("=================================================");
log.warn("ToolMcpServerConfiguration IS LOADED BY SPRING!!!");
log.warn("=================================================");
}
@Bean
@Primary
public HttpServletStreamableServerTransportProvider toolMcpTransportProvider() {
@@ -21,6 +31,8 @@ public class ToolMcpServerConfiguration {
@Bean
public ServletRegistrationBean<HttpServletStreamableServerTransportProvider> toolMcpServlet(
HttpServletStreamableServerTransportProvider transportProvider) {
return new ServletRegistrationBean<>(transportProvider, "/mcp");
// "/mcp/*"로 매핑하면 BusinessToolController의 "/mcp/api/v1/tools/local" 까지 가로채게 되므로,
// 정확히 MCP 통신에 사용되는 "/mcp" 와 "/mcp/message" 두 개만 매핑합니다.
return new ServletRegistrationBean<>(transportProvider, "/mcp", "/mcp/message");
}
}

View File

@@ -16,8 +16,8 @@ package io.shinhanlife.dap.lib.mcp;
* </pre>
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
@@ -82,60 +82,64 @@ public class ToolRegistryHeartbeatSender {
for (Object bean : allBeans.values()) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
// 클래스 또는 프록시(인터페이스)에서 @McpTool 스캔
McpTool toolAnnotation = AnnotationUtils.findAnnotation(targetClass, McpTool.class);
if (toolAnnotation == null) {
toolAnnotation = AnnotationUtils.findAnnotation(bean.getClass(), McpTool.class);
}
for (Method method : targetClass.getDeclaredMethods()) {
// 메서드, 수퍼클래스, 인터페이스를 모두 뒤져서 @McpFunction 스캔
McpFunction functionAnnotation = AnnotationUtils.findAnnotation(method, McpFunction.class);
McpTool functionAnnotation = AnnotationUtils.findAnnotation(method, McpTool.class);
ToolHint hintAnnotation = AnnotationUtils.findAnnotation(method, ToolHint.class);
if (functionAnnotation != null && toolAnnotation != null) {
String baseName = functionAnnotation.displayName();
if (functionAnnotation != null) {
String baseName = functionAnnotation.name();
String rawSubToolName = functionAnnotation.name();
String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + rawSubToolName
: rawSubToolName;
boolean isRegister = functionAnnotation.register();
boolean isRegister = hintAnnotation != null && hintAnnotation.register();
if (!isRegister) {
log.info(" [HeartbeatSender] '{}' 툴은 어노테이션 설정에 의해 외부 등록(Redis) 대상에서 제외되었습니다. (최종 이름: {})", baseName, subToolName);
}
ToolMetadata meta = new ToolMetadata();
meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString());
meta.setDisplayName(baseName);
String displayName = functionAnnotation.title().isEmpty() ? functionAnnotation.name() : functionAnnotation.title();
meta.setDisplayName(displayName);
meta.setName(subToolName);
meta.setSemver(functionAnnotation.version());
meta.setTimeoutMillis(functionAnnotation.timeoutMillis());
meta.setEnabled(functionAnnotation.enabled());
meta.setSemver("1.0.0");
meta.setTimeoutMillis(5000L);
meta.setEnabled(true);
meta.setDescription(functionAnnotation.description());
meta.setCategoryKey(toolAnnotation.categoryKey());
meta.setIntegrationType(toolAnnotation.routingType());
meta.setMciServiceId(functionAnnotation.mappingId());
meta.setCategoryKey(hintAnnotation == null || hintAnnotation.categoryKey().isBlank()
? "common" : hintAnnotation.categoryKey());
meta.setIntegrationType("REST");
meta.setMciServiceId(hintAnnotation != null ? hintAnnotation.mappingId() : "");
meta.setPodUrl(podUrl);
meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName);
boolean isVisible = functionAnnotation.visible();
meta.setVisible(isVisible);
meta.setVisible(true);
meta.setIsRegistered(isRegister);
meta.setRequiresApproval(functionAnnotation.requiresApproval());
meta.setReadOnlyHint(functionAnnotation.readOnlyHint());
meta.setDestructiveHint(functionAnnotation.destructiveHint());
meta.setIdempotentHint(functionAnnotation.idempotentHint());
meta.setOpenWorldHint(functionAnnotation.openWorldHint());
meta.setRequiresApproval(hintAnnotation != null && hintAnnotation.requiresApproval());
// extract standard hints from @McpTool.annotations()
McpTool.McpAnnotations ann = functionAnnotation.annotations();
if (ann != null) {
meta.setReadOnlyHint(ann.readOnlyHint());
meta.setDestructiveHint(ann.destructiveHint());
meta.setIdempotentHint(ann.idempotentHint());
meta.setOpenWorldHint(ann.openWorldHint());
} else {
meta.setReadOnlyHint(false);
meta.setDestructiveHint(true);
meta.setIdempotentHint(false);
meta.setOpenWorldHint(true);
}
Map<String, String> prompts = new HashMap<>();
String promptText = functionAnnotation.prompt();
prompts.put(subToolName, promptText);
meta.setActionPrompts(prompts);
if (method.getParameterCount() > 0) {
try {
Class<?> paramType = method.getParameterTypes()[0];
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, paramType);
// TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType);
meta.setParametersSchema(finalSchema);
} catch (Exception e) {
log.error("Failed to generate schema for {}", subToolName, e);

View File

@@ -2,9 +2,8 @@ package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import io.shinhanlife.dap.lib.annotation.McpAnyOf;
import org.springframework.ai.mcp.annotation.McpToolParam;
import io.swagger.v3.oas.annotations.media.Schema;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
@@ -56,7 +55,7 @@ public class JsonSchemaGenerator {
// 1. 타입 매핑
// 2. 어노테이션 기반 설명 추출
McpParameter paramAnnotation = field.getAnnotation(McpParameter.class);
McpToolParam paramAnnotation = field.getAnnotation(McpToolParam.class);
JsonPropertyDescription descAnnotation = field.getAnnotation(JsonPropertyDescription.class);
if (paramAnnotation != null && !paramAnnotation.description().isEmpty()) {
fieldSchema.put("description", paramAnnotation.description());
@@ -72,46 +71,54 @@ public class JsonSchemaGenerator {
requiredList.add(field.getName());
}
McpValidation validation = field.getAnnotation(McpValidation.class);
if (validation != null && validation.required() && !requiredList.contains(field.getName())) {
requiredList.add(field.getName());
Schema schemaAnnotation = field.getAnnotation(Schema.class);
if (schemaAnnotation != null) {
if (!schemaAnnotation.description().isEmpty() && !fieldSchema.containsKey("description")) {
fieldSchema.put("description", schemaAnnotation.description());
}
if (schemaAnnotation.required() && !requiredList.contains(field.getName())) {
requiredList.add(field.getName());
}
if (!schemaAnnotation.pattern().isEmpty()) {
fieldSchema.put("pattern", schemaAnnotation.pattern());
}
if (!schemaAnnotation.minimum().isEmpty()) {
try {
fieldSchema.put("minimum", Long.valueOf(schemaAnnotation.minimum()));
} catch (NumberFormatException ignored) {}
}
if (!schemaAnnotation.maximum().isEmpty()) {
try {
fieldSchema.put("maximum", Long.valueOf(schemaAnnotation.maximum()));
} catch (NumberFormatException ignored) {}
}
if (schemaAnnotation.minLength() > 0) {
fieldSchema.put("minLength", schemaAnnotation.minLength());
}
if (schemaAnnotation.maxLength() > 0 && schemaAnnotation.maxLength() != Integer.MAX_VALUE) {
fieldSchema.put("maxLength", schemaAnnotation.maxLength());
}
if (schemaAnnotation.allowableValues().length > 0 && !schemaAnnotation.allowableValues()[0].isEmpty()) {
fieldSchema.put("enum", List.of(schemaAnnotation.allowableValues()));
}
if (!schemaAnnotation.format().isEmpty()) {
fieldSchema.put("format", schemaAnnotation.format());
}
if (!schemaAnnotation.defaultValue().isEmpty()) {
fieldSchema.put("default", coerceDefaultValue(schemaAnnotation.defaultValue(), field.getType()));
}
if (!schemaAnnotation.example().isEmpty()) {
fieldSchema.put("examples", List.of(schemaAnnotation.example()));
}
if (schemaAnnotation.nullable()) {
Map<String, Object> nonNullSchema = new HashMap<>(fieldSchema);
fieldSchema = new HashMap<>();
fieldSchema.put("anyOf", List.of(
nonNullSchema,
Map.of("type", "null")
));
}
}
if (validation != null && !validation.pattern().isEmpty()) {
fieldSchema.put("pattern", validation.pattern());
}
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()));
}
if (validation != null && !validation.format().isEmpty()) {
fieldSchema.put("format", validation.format());
}
if (validation != null && !validation.defaultValue().isEmpty()) {
fieldSchema.put("default", coerceDefaultValue(validation.defaultValue(), field.getType()));
}
if (validation != null && validation.examples().length > 0) {
fieldSchema.put("examples", List.of(validation.examples()));
}
if (validation != null && validation.nullable()) {
Map<String, Object> nonNullSchema = new HashMap<>(fieldSchema);
fieldSchema = new HashMap<>();
fieldSchema.put("anyOf", List.of(
nonNullSchema,
Map.of("type", "null")
));
}
properties.put(field.getName(), fieldSchema);
}
@@ -120,15 +127,7 @@ public class JsonSchemaGenerator {
schema.put("required", requiredList);
}
McpAnyOf anyOfAnnotation = clazz.getAnnotation(McpAnyOf.class);
if (anyOfAnnotation != null && anyOfAnnotation.value().length > 0) {
List<Map<String, Object>> anyOfList = new ArrayList<>();
for (String fieldName : anyOfAnnotation.value()) {
anyOfList.add(Map.of("required", List.of(fieldName)));
}
schema.put("anyOf", anyOfList);
}
// anyOf removed
visiting.remove(clazz);
return schema;

View File

@@ -1,5 +1,7 @@
package io.shinhanlife.dap.lib.util;
import org.springframework.ai.mcp.annotation.McpToolParam;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -60,7 +62,7 @@ public class ToolScaffolder {
if (moduleName.trim().isEmpty()) {
moduleName = "dap-was-oth";
}
String defaultAuthor = System.getProperty("user.name");
String defaultDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
@@ -69,7 +71,13 @@ public class ToolScaffolder {
String createDate = getOrAsk(args, 7, scanner, "8. 작성일 (엔터 입력 시 '" + defaultDate + "'): ");
if (createDate.trim().isEmpty()) createDate = defaultDate;
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, true, null);
String useSchemaResourceStr = getOrAsk(args, 8, scanner, "9. input/output JSON Schema 파일 자동 생성 여부 (y/N): ");
boolean useSchemaResource = "y".equalsIgnoreCase(useSchemaResourceStr.trim());
String inputSchemaResource = useSchemaResource ? "classpath:mcp/schema/" + toKebabCase(baseName) + "-resource-input-schema.json" : null;
String outputSchemaResource = useSchemaResource ? "classpath:mcp/schema/" + toKebabCase(baseName) + "-resource-output-schema.json" : null;
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, true, null, inputSchemaResource, outputSchemaResource);
System.out.println(result);
}
@@ -82,6 +90,10 @@ public class ToolScaffolder {
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode) throws IOException {
return scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, register, clientSystemCode, null, null);
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource) throws IOException {
baseName = toPascalCase(baseName);
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
@@ -93,6 +105,15 @@ public class ToolScaffolder {
Path legacyDtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "legacy"));
Path converterDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "biz", group.toLowerCase(), "converter"));
// schema resource 파일 경로 (useSchemaResource=true 일 때만 생성)
boolean useSchemaResource = (inputSchemaResource != null && !inputSchemaResource.trim().isEmpty()) || (outputSchemaResource != null && !outputSchemaResource.trim().isEmpty());
String schemaBaseName = toKebabCase(baseName);
String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json";
String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json";
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mcp/schema"));
String inputSchemaClasspath = "classpath:mcp/schema/" + inputSchemaFileName;
String outputSchemaClasspath = "classpath:mcp/schema/" + outputSchemaFileName;
String bizPackage = BASE_PACKAGE + ".biz." + group.toLowerCase();
boolean isMci = "MCI".equalsIgnoreCase(routingType);
String mciGroupPath = "infra/itrf/mci/" + group.toLowerCase();
@@ -130,8 +151,6 @@ public class ToolScaffolder {
package %s.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import lombok.Data;
/**
@@ -151,12 +170,11 @@ public class ToolScaffolder {
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %sRequest {
@McpParameter(description = "수신자 전화번호", required = true)
@McpValidation(pattern = "^01(?:0|1|[6-9])-(?:\\\\d{3}|\\\\d{4})-\\\\d{4}$", examples = {"010-1234-5678"})
@McpToolParam(description = "수신자 전화번호", required = true)
-(?:\\\\d{3}|\\\\d{4})-\\\\d{4}$", examples = {"010-1234-5678"})
private String phoneNumber;
@McpParameter(description = "전송할 메시지 내용", required = true)
@McpValidation(defaultValue = "안녕하세요.")
@McpToolParam(description = "전송할 메시지 내용", required = true)
private String message;
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
@@ -167,9 +185,7 @@ public class ToolScaffolder {
package %s.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import lombok.Data;
import lombok.Data;
/**
* @package %s.dto
@@ -186,13 +202,10 @@ public class ToolScaffolder {
* </pre>
*/
@Data
@McpOutputSchema
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %sResponse {
@McpValidation(required = true)
private String resultCode;
@McpValidation(maxLength = 200, nullable = true)
private String resultMessage;
// TODO: Add response fields here. Do not include PII in the Tool response.
@@ -202,42 +215,54 @@ public class ToolScaffolder {
String toolName = toToolName(moduleName, group, baseName);
String toolHintLine;
if (useSchemaResource) {
toolHintLine = " @ToolHint(register = %s,\n" +
" inputSchemaResource = \"%s\",\n" +
" outputSchemaResource = \"%s\")".formatted(register, inputSchemaClasspath, outputSchemaClasspath);
} else {
toolHintLine = " @ToolHint(register = %s)".formatted(register);
}
String serviceInterfaceContent = """
package %s.usecase;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
@McpTool(
routingType = "%s",
categoryKey = "%s"
)
/**
* @package %s.usecase
* @className %sUseCase
* @description AX HUB 시스템 처리 클래스
* @author %s
* @create %s
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* %s %s 최초생성
*
* </pre>
*/
public interface %sUseCase {
@McpFunction(
displayName = "%s",
name = "%s",
description = "%s",
prompt = "%s",
mappingId = "%s",
register = %s,
requiresApproval = false,
openWorldHint = true,
version = "1.0.0",
timeoutMillis = 300000L,
enabled = true
)
Object execute(%sRequest req);
@McpTool(name = "%s", title = "%s", description = "%s")
%s
%sResponse execute(%sRequest req);
}
""".formatted(
bizPackage,
bizPackage,
bizPackage, baseName,
routingType, group.toLowerCase(),
bizPackage, baseName,
bizPackage, baseName, author, createDate, createDate, author,
baseName,
baseName, toolName, description, description + " ?줘.", interfaceId, register,
baseName
toolName, description, description,
toolHintLine,
baseName, baseName
);
Files.writeString(usecaseDir.resolve(baseName + "UseCase.java"), serviceInterfaceContent);
String serviceImplContent;
@@ -667,11 +692,58 @@ public class ToolScaffolder {
log.append("[Legacy Response DTO] ").append(legacyDtoDir.resolve(baseName + "LegacyResponse.java")).append("\n");
log.append("[Legacy Converter] ").append(converterDir.resolve(baseName + "Converter.java")).append("\n");
}
// schema resource 파일 생성 (useSchemaResource=true 일 때)
if (useSchemaResource) {
Files.createDirectories(schemaDir);
String inputSchema = """
{
"type": "object",
"additionalProperties": false,
"properties": {
"TODO_FIELD": {
"type": "string",
"description": "TODO: 파라미터 설명을 입력하세요."
}
},
"required": []
}
""";
String outputSchema = """
{
"type": "object",
"additionalProperties": false,
"properties": {
"status": {
"type": "string",
"description": "처리 결과 상태 (SUCCESS / FAILURE)",
"enum": ["SUCCESS", "FAILURE"]
},
"message": {
"type": "string",
"description": "처리 결과 메시지"
}
},
"required": ["status"]
}
""";
Files.writeString(schemaDir.resolve(inputSchemaFileName), inputSchema);
Files.writeString(schemaDir.resolve(outputSchemaFileName), outputSchema);
log.append("[Input Schema] ").append(schemaDir.resolve(inputSchemaFileName)).append("\n");
log.append("[Output Schema] ").append(schemaDir.resolve(outputSchemaFileName)).append("\n");
}
log.append("\n Tip: ").append(interfaceId).append(" 목업 데이터를 mock-responses.json에 추가하세요.\n");
return log.toString();
}
private static String toKebabCase(String pascalCase) {
if (pascalCase == null || pascalCase.isEmpty()) return pascalCase;
return pascalCase
.replaceAll("([a-z0-9])([A-Z])", "$1-$2")
.toLowerCase(Locale.ROOT);
}
private static String toToolName(String moduleName, String group, String baseName) {
String moduleDirectory = Path.of(moduleName).getFileName().toString();
String pod = moduleDirectory.startsWith("dap-was-")

View File

@@ -2,11 +2,13 @@ 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 io.shinhanlife.dap.lib.annotation.McpOutputSchema;import java.io.InputStream;
// removed McpOutputSchema
import java.io.InputStream;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import io.shinhanlife.dap.lib.annotation.ToolHint;
/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */
public class ToolSchemaResolver {
@@ -16,13 +18,9 @@ public class ToolSchemaResolver {
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");
public Map<String, Object> resolve(org.springframework.ai.mcp.annotation.McpTool function, ToolHint hint, Class<?> requestType) {
if (hint != null && !hint.inputSchemaResource().isBlank()) {
return loadResource(hint.inputSchemaResource());
}
return JsonSchemaGenerator.generateSchema(requestType);
}
@@ -30,25 +28,36 @@ public class ToolSchemaResolver {
/**
* Resolves an explicitly declared response schema.
* Response schemas are opt-in so existing tools keep their current response behavior.
* ToolHint.outputSchemaResource()가 있으면 classpath JSON 파일에서 로드하고,
* 없으면 responseType DTO를 분석하여 자동 생성합니다.
*/
public Map<String, Object> resolveOutput(McpFunction function, Class<?> responseType) {
if (function != null && !function.outputSchemaResource().isBlank()) {
return loadResource(function.outputSchemaResource());
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function, Class<?> responseType, ToolHint hint) {
if (hint != null && !hint.outputSchemaResource().isBlank()) {
return loadResource(hint.outputSchemaResource());
}
if (function != null && !function.outputSchema().isBlank()
&& !"{}".equals(function.outputSchema().trim())) {
return parse(function.outputSchema(), "McpFunction.outputSchema");
return resolveOutput(function, responseType);
}
/**
* Resolves an explicitly declared response schema.
* Response schemas are opt-in so existing tools keep their current response behavior.
*/
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function, Class<?> responseType) {
// Object, Map 등 구체적인 DTO가 아닌 경우 검증 스킵
if (responseType == null
|| responseType == Object.class
|| Map.class.isAssignableFrom(responseType)
|| responseType == Void.class
|| responseType == void.class) {
return Map.of();
}
if (responseType != null && responseType.isAnnotationPresent(McpOutputSchema.class)) {
return JsonSchemaGenerator.generateSchema(responseType);
}
return Map.of();
return JsonSchemaGenerator.generateSchema(responseType);
}
/**
* Retained for callers that use only explicit output schemas.
*/
public Map<String, Object> resolveOutput(McpFunction function) {
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function) {
return resolveOutput(function, null);
}

View File

@@ -1,123 +1,140 @@
package io.shinhanlife.dap.lib.util;
/**
* @package io.shinhanlife.dap.lib.util
* @className ToolSourceUpdater
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.Comparator;
public class ToolSourceUpdater {
/** Updates MCP SDK and project-owned metadata in a generated tool source file. */
public final class ToolSourceUpdater {
public static void updateToolSource(String toolName, String domainGroup, String description, boolean register, Boolean requiresApproval) throws Exception {
// 1. Find all *UseCase.java files in dap-was-* directories
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
List<Path> javaFiles;
try (Stream<Path> paths = Files.walk(rootDir)) {
javaFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith("UseCase.java"))
.filter(p -> p.toString().contains("dap-was-") || p.toString().contains("axhub-tool-"))
.collect(Collectors.toList());
}
private ToolSourceUpdater() {
}
Path targetFile = null;
String content = null;
// 2. Find the specific file for the tool
String functionName = toolName;
if (toolName.contains("_")) {
functionName = toolName.substring(toolName.indexOf("_") + 1);
}
Pattern namePattern = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"", Pattern.DOTALL);
Pattern namePattern2 = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(functionName) + "\"", Pattern.DOTALL);
for (Path path : javaFiles) {
String text = Files.readString(path);
if (namePattern.matcher(text).find()) {
targetFile = path;
content = text;
break;
} else if (namePattern2.matcher(text).find()) {
targetFile = path;
content = text;
toolName = functionName; // Use baseName for subsequent replacements
break;
}
}
public static void updateToolSource(String toolName, String categoryKey, String description,
boolean register, Boolean requiresApproval) throws IOException {
String configuredSourceDirectory = System.getenv("AXHUB_SOURCE_DIR");
Path rootDirectory = configuredSourceDirectory == null || configuredSourceDirectory.isBlank()
? Paths.get(".") : Paths.get(configuredSourceDirectory);
updateToolSource(rootDirectory, toolName, categoryKey, description, register, requiresApproval);
}
static void updateToolSource(Path rootDirectory, String toolName, String categoryKey, String description,
boolean register, Boolean requiresApproval) throws IOException {
Path targetFile = findToolSource(rootDirectory, toolName);
if (targetFile == null) {
throw new Exception("소스 코드를 찾을 수 없습니다: " + toolName);
throw new IllegalArgumentException("Tool source not found: " + toolName);
}
// 3. Update @McpTool group
if (domainGroup != null && !domainGroup.trim().isEmpty()) {
Pattern groupPattern = Pattern.compile("(@McpTool\\s*\\([^)]*group\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
Matcher groupMatcher = groupPattern.matcher(content);
if (groupMatcher.find()) {
content = groupMatcher.replaceFirst("$1" + domainGroup + "$3");
}
}
// 4. Update @McpFunction description
if (description != null) {
Pattern funcPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*description\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
Matcher funcMatcher = funcPattern.matcher(content);
if (funcMatcher.find()) {
content = funcMatcher.replaceFirst("$1" + description.replace("\\", "\\\\").replace("$", "\\\\$") + "$3");
}
}
// 5. Update register flag
Pattern regPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*register\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
Matcher regMatcher = regPattern.matcher(content);
if (regMatcher.find()) {
content = regMatcher.replaceFirst("$1" + register + "$3");
} else {
Pattern addRegPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
Matcher addRegMatcher = addRegPattern.matcher(content);
if (addRegMatcher.find()) {
content = addRegMatcher.replaceFirst("$1, register = " + register);
}
}
// 5.5 Update requiresApproval flag
if (requiresApproval != null) {
Pattern appPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*requiresApproval\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
Matcher appMatcher = appPattern.matcher(content);
if (appMatcher.find()) {
content = appMatcher.replaceFirst("$1" + requiresApproval + "$3");
} else {
Pattern addAppPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
Matcher addAppMatcher = addAppPattern.matcher(content);
if (addAppMatcher.find()) {
content = addAppMatcher.replaceFirst("$1, requiresApproval = " + requiresApproval);
}
}
}
// 6. Write back to file
String content = Files.readString(targetFile);
content = updateMcpTool(content, toolName, description);
content = updateToolHint(content, categoryKey, register, requiresApproval);
Files.writeString(targetFile, content);
}
private static Path findToolSource(Path rootDirectory, String toolName) throws IOException {
try (var paths = Files.walk(rootDirectory)) {
return paths.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith("UseCase.java"))
.filter(path -> path.toString().contains("dap-was-") || path.toString().contains("axhub-tool-"))
.sorted(Comparator.naturalOrder())
.filter(path -> containsMcpTool(path, toolName))
.findFirst()
.orElse(null);
}
}
private static boolean containsMcpTool(Path path, String toolName) {
try {
return annotationArguments(Files.readString(path), "McpTool", toolName) != null;
} catch (IOException exception) {
throw new IllegalStateException("Failed to read tool source: " + path, exception);
}
}
private static String updateMcpTool(String content, String toolName, String description) {
AnnotationRange range = annotationArguments(content, "McpTool", toolName);
if (range == null) {
throw new IllegalArgumentException("McpTool declaration not found: " + toolName);
}
return description == null ? content : replaceAttribute(content, range, "description", quote(description));
}
private static String updateToolHint(String content, String categoryKey, boolean register, Boolean requiresApproval) {
AnnotationRange range = annotationArguments(content, "ToolHint", null);
if (range == null) {
throw new IllegalArgumentException("ToolHint declaration not found next to McpTool");
}
String updated = replaceAttribute(content, range, "register", Boolean.toString(register));
range = annotationArguments(updated, "ToolHint", null);
if (requiresApproval != null) {
updated = replaceAttribute(updated, range, "requiresApproval", Boolean.toString(requiresApproval));
range = annotationArguments(updated, "ToolHint", null);
}
if (categoryKey != null && !categoryKey.isBlank()) {
updated = replaceAttribute(updated, range, "categoryKey", quote(categoryKey));
}
return updated;
}
private static String replaceAttribute(String content, AnnotationRange range, String attribute, String value) {
String arguments = content.substring(range.argumentsStart(), range.argumentsEnd());
String pattern = "\\b" + attribute + "\\s*=\\s*(?:true|false|\\\"(?:\\\\.|[^\\\"\\\\])*\\\")";
String replacement = arguments.replaceFirst(pattern, attribute + " = " + value);
if (replacement.equals(arguments)) {
replacement = arguments.isBlank() ? attribute + " = " + value : arguments + ", " + attribute + " = " + value;
}
return content.substring(0, range.argumentsStart()) + replacement + content.substring(range.argumentsEnd());
}
private static String quote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
private static AnnotationRange annotationArguments(String content, String annotationName, String toolName) {
int offset = content.indexOf("@" + annotationName);
while (offset >= 0) {
int openingParenthesis = content.indexOf('(', offset);
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
if (openingParenthesis < 0 || closingParenthesis < 0) {
return null;
}
AnnotationRange range = new AnnotationRange(openingParenthesis + 1, closingParenthesis);
if (toolName == null || content.substring(range.argumentsStart(), range.argumentsEnd())
.matches("(?s).*\\bname\\s*=\\s*\\\"" + java.util.regex.Pattern.quote(toolName) + "\\\".*")) {
return range;
}
offset = content.indexOf("@" + annotationName, closingParenthesis + 1);
}
return null;
}
private static int findAnnotationEnd(String content, int openingParenthesis) {
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int index = openingParenthesis; index < content.length(); index++) {
char character = content.charAt(index);
if (inString) {
if (escaped) {
escaped = false;
} else if (character == '\\') {
escaped = true;
} else if (character == '\"') {
inString = false;
}
} else if (character == '\"') {
inString = true;
} else if (character == '(') {
depth++;
} else if (character == ')' && --depth == 0) {
return index;
}
}
return -1;
}
private record AnnotationRange(int argumentsStart, int argumentsEnd) {
}
}

View File

@@ -15,7 +15,7 @@ import java.util.regex.Pattern;
/**
* @package io.shinhanlife.dap.lib.validation
* @className McpToolNameValidator
* @description Validates unique MCP function names across tool modules
* @description Validates unique MCP SDK tool names across tool modules
* @author 0986406
* @create 2026.07.27
* <pre>
@@ -88,7 +88,7 @@ public final class McpToolNameValidator {
throw new UncheckedIOException("Failed to read " + source, exception);
}
int annotationOffset = content.indexOf("@McpFunction");
int annotationOffset = content.indexOf("@McpTool");
while (annotationOffset >= 0) {
int openingParenthesis = content.indexOf('(', annotationOffset);
int closingParenthesis = findAnnotationEnd(content, openingParenthesis);
@@ -103,7 +103,7 @@ public final class McpToolNameValidator {
declarationsByName.computeIfAbsent(toolName, ignored -> new ArrayList<>())
.add(new ToolDeclaration(moduleName, source, line));
}
annotationOffset = content.indexOf("@McpFunction", closingParenthesis + 1);
annotationOffset = content.indexOf("@McpTool", closingParenthesis + 1);
}
}

View File

@@ -17,9 +17,8 @@ package io.shinhanlife.dap.mcc.presentation;
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import org.springframework.ai.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
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.mcp.ToolRegistryHeartbeatSender;
@@ -88,15 +87,15 @@ public class BusinessToolController {
}
Object targetBean = null;
Method targetMethod = null;
McpFunction targetFunctionAnnotation = null;
McpTool targetFunctionAnnotation = null;
// McpTool 어노테이션 기반 조회가 프록시 문제로 누락될 수 있으므로, 전체 빈을 순회하며 @McpFunction을 찾습니다.
// McpTool 어노테이션 기반 조회가 프록시 문제로 누락될 수 있으므로, 전체 빈을 순회하며 @McpTool을 찾습니다.
Map<String, Object> allBeans = applicationContext.getBeansOfType(Object.class);
outerLoop:
for (Object bean : allBeans.values()) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
for (Method targetMethodOfClass : targetClass.getDeclaredMethods()) {
McpFunction mcpFunc = AnnotationUtils.findAnnotation(targetMethodOfClass, McpFunction.class);
McpTool mcpFunc = AnnotationUtils.findAnnotation(targetMethodOfClass, McpTool.class);
if (mcpFunc != null) {
String baseName = mcpFunc.name();
String expectedName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
@@ -122,7 +121,7 @@ public class BusinessToolController {
for (Object bean : allBeans.values()) {
Class<?> targetCls = AopUtils.getTargetClass(bean);
for (Method m : targetCls.getDeclaredMethods()) {
McpFunction func = AnnotationUtils.findAnnotation(m, McpFunction.class);
McpTool func = AnnotationUtils.findAnnotation(m, McpTool.class);
if (func != null) {
String baseName = func.name();
String expName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
@@ -149,7 +148,8 @@ public class BusinessToolController {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
try {
Map<String, Object> inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, paramType);
io.shinhanlife.dap.lib.annotation.ToolHint hint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class);
Map<String, Object> inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, hint, paramType);
List<Error> errors = toolArgumentSchemaValidator.validate(inputSchema, arguments);
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
@@ -194,7 +194,8 @@ public class BusinessToolController {
methodResult = targetMethod.invoke(targetBean, invokeArgument);
}
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType());
io.shinhanlife.dap.lib.annotation.ToolHint outputHint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class);
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType(), outputHint);
if (!outputSchema.isEmpty()) {
List<Error> outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult);
if (!outputErrors.isEmpty()) {