Fix scaffold errors and apply ToolHint annotations
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 33s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 33s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user