From 6a6e3eaac6ecee79c9e93b73e5a75987b4f2fc3f Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 18 Aug 2026 18:11:04 +0900 Subject: [PATCH] =?UTF-8?q?=ED=88=B4=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20in?= =?UTF-8?q?terface=20->=20=EA=B5=AC=ED=98=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dap/lib/mcp/McpToolMethodRegistry.java | 144 +++++++++- .../lib/mcp/ToolRegistryHeartbeatSender.java | 269 ++++++++++++------ 2 files changed, 320 insertions(+), 93 deletions(-) diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolMethodRegistry.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolMethodRegistry.java index 86d99947f..91ef0aecd 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolMethodRegistry.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolMethodRegistry.java @@ -2,9 +2,11 @@ package io.shinhanlife.dap.lib.mcp; import io.shinhanlife.dap.lib.annotation.GrowToolHint; import io.shinhanlife.dap.lib.config.McpProperties; + import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.Map; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springaicommunity.mcp.annotation.McpTool; @@ -14,6 +16,8 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.event.EventListener; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.stereotype.Component; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; /** @@ -31,26 +35,46 @@ public class McpToolMethodRegistry { @EventListener(ApplicationReadyEvent.class) public void initialize() { - Map discovered = new LinkedHashMap<>(); + Map discovered = new LinkedHashMap<>(); for (Object bean : applicationContext.getBeansOfType(Object.class).values()) { Class targetClass = AopUtils.getTargetClass(bean); for (Method declaredMethod : targetClass.getDeclaredMethods()) { - McpTool annotation = AnnotationUtils.findAnnotation(declaredMethod, McpTool.class); - if (annotation == null) { + + /* + * Tool Annotation 검색 + * + * 1순위 : Interface + * 2순위 : 구현체 + */ + ToolAnnotationMetadata metadata = findToolAnnotationMetadata(targetClass, declaredMethod); + + if (metadata == null) { continue; } - RegisteredTool tool = new RegisteredTool( - bean, - findInvocableMethod(bean, declaredMethod), - annotation, - AnnotationUtils.findAnnotation(declaredMethod, GrowToolHint.class)); + McpTool annotation = metadata.mcpTool(); + GrowToolHint hint = metadata.growToolHint(); + + /* + * Annotation은 Interface에서 가져올 수 있지만 + * 실제 호출 Method는 구현체 Method를 사용한다. + */ + RegisteredTool tool = new RegisteredTool(bean, findInvocableMethod(bean, declaredMethod), annotation, hint); + + /* + * 원래 Tool Name 등록 + */ register(discovered, annotation.name(), tool); + + /* + * namespace alias 등록 + */ registerNamespaceAlias(discovered, annotation.name(), tool); + + log.info("[Tool Registry] Registered Tool: {} -> {}#{}", annotation.name(), targetClass.getSimpleName(), declaredMethod.getName()); } } - tools = Map.copyOf(discovered); log.info("[Tool Registry] {} executable tool names cached", tools.size()); } @@ -59,22 +83,111 @@ public class McpToolMethodRegistry { return tools.get(toolName); } - private void registerNamespaceAlias(Map discovered, String toolName, - RegisteredTool tool) { + /** + * @McpTool / @GrowToolHint 검색 + *

+ * 우선순위 + *

+ * 1. Interface Method + * 2. 구현체 Method + */ + private ToolAnnotationMetadata findToolAnnotationMetadata(Class targetClass, Method declaredMethod) { + + /* + * ========================================== + * STEP 1. + * Interface부터 검색 + * ========================================== + */ + for (Class interfaceClass : ClassUtils.getAllInterfacesForClassAsSet(targetClass)) { + + /* + * 구현체 Method와 동일한 Signature를 가진 + * Interface Method를 찾는다. + */ + Method interfaceMethod = ReflectionUtils.findMethod(interfaceClass, declaredMethod.getName(), declaredMethod.getParameterTypes()); + + if (interfaceMethod == null) { + continue; + } + + /* + * Interface의 @McpTool 검색 + */ + McpTool mcpTool = AnnotationUtils.findAnnotation(interfaceMethod, McpTool.class); + if (mcpTool == null) { + continue; + } + + /* + * @McpTool을 Interface에서 발견했다면 + * @GrowToolHint 역시 같은 Interface Method에서 가져온다. + */ + GrowToolHint growToolHint = AnnotationUtils.findAnnotation(interfaceMethod, GrowToolHint.class); + log.debug("[Tool Registry] Interface @McpTool found: " + "{}#{} -> {}", interfaceClass.getSimpleName(), interfaceMethod.getName(), mcpTool.name()); + return new ToolAnnotationMetadata(mcpTool, growToolHint); + } + + /* + * ========================================== + * STEP 2. + * Interface에서 발견되지 않은 경우 + * 구현체 Method 검색 + * ========================================== + */ + McpTool mcpTool = AnnotationUtils.findAnnotation(declaredMethod, McpTool.class); + + if (mcpTool == null) { + return null; + } + + GrowToolHint growToolHint = AnnotationUtils.findAnnotation(declaredMethod, GrowToolHint.class); + log.debug("[Tool Registry] Implementation @McpTool found: " + "{}#{} -> {}", targetClass.getSimpleName(), declaredMethod.getName(), mcpTool.name()); + return new ToolAnnotationMetadata(mcpTool, growToolHint); + } + + /** + * Namespace가 존재하는 경우 + *

+ * ex) + *

+ * pct_notice_list + *

+ * namespace = sys + *

+ * sys_pct_notice_list + *

+ * 두 이름으로 동일 Tool을 조회할 수 있도록 등록 + */ + private void registerNamespaceAlias(Map discovered, String toolName, RegisteredTool tool) { + String namespace = mcpProperties.getNamespace(); + if (StringUtils.hasText(namespace)) { register(discovered, namespace + "_" + toolName, tool); } } + /** + * Tool Registry 등록 + */ private void register(Map discovered, String toolName, RegisteredTool tool) { + RegisteredTool existing = discovered.putIfAbsent(toolName, tool); + if (existing != null && existing != tool) { throw new IllegalStateException("Duplicate @McpTool name: " + toolName); } } + /** + * 실제 호출 가능한 Method 확보 + *

+ * Annotation 검색은 Interface에서 하더라도 + * Tool 실행은 구현체 Bean을 대상으로 수행해야 한다. + */ private Method findInvocableMethod(Object bean, Method declaredMethod) { + try { return bean.getClass().getMethod(declaredMethod.getName(), declaredMethod.getParameterTypes()); } catch (NoSuchMethodException ignored) { @@ -82,6 +195,15 @@ public class McpToolMethodRegistry { } } + /** + * Annotation 검색 결과 + */ + private record ToolAnnotationMetadata(McpTool mcpTool, GrowToolHint growToolHint) { + } + + /** + * 실제 실행 Registry 정보 + */ public record RegisteredTool(Object bean, Method method, McpTool annotation, GrowToolHint hint) { } } \ No newline at end of file diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java index 9fa6ca14e..845505fd4 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java @@ -15,32 +15,36 @@ package io.shinhanlife.dap.lib.mcp; * * */ + import com.fasterxml.jackson.databind.ObjectMapper; -import org.springaicommunity.mcp.annotation.McpTool; import io.shinhanlife.dap.lib.annotation.GrowToolHint; import io.shinhanlife.dap.lib.config.McpProperties; -import io.shinhanlife.dap.mcc.dto.ToolMetadata; -import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.lib.metadata.ToolDefinition; import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository; +import io.shinhanlife.dap.lib.util.ToolSchemaResolver; +import io.shinhanlife.dap.mcc.dto.ToolMetadata; import jakarta.annotation.PostConstruct; + import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; + import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.springaicommunity.mcp.annotation.McpTool; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.context.ApplicationContext; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; @Slf4j @Component @@ -54,9 +58,8 @@ public class ToolRegistryHeartbeatSender { private final ToolDefinitionRepository toolDefinitionRepository; @Autowired - public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper, - McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver, - @Nullable ToolDefinitionRepository toolDefinitionRepository) { + public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper, McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver, @Nullable ToolDefinitionRepository toolDefinitionRepository) { + this.applicationContext = applicationContext; this.objectMapper = objectMapper; this.mcpProperties = mcpProperties; @@ -64,8 +67,8 @@ public class ToolRegistryHeartbeatSender { this.toolDefinitionRepository = toolDefinitionRepository; } - public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper, - McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver) { + public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper, McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver) { + this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null); } @@ -85,94 +88,187 @@ public class ToolRegistryHeartbeatSender { } private void scanAndBuildMetadata() { + + /* + * 재호출될 가능성을 고려해서 + * 기존 Metadata 제거 + */ + allScannedTools.clear(); Map allBeans = applicationContext.getBeansOfType(Object.class); for (Object bean : allBeans.values()) { Class targetClass = AopUtils.getTargetClass(bean); - for (Method method : targetClass.getDeclaredMethods()) { - McpTool functionAnnotation = AnnotationUtils.findAnnotation(method, McpTool.class); - GrowToolHint hintAnnotation = AnnotationUtils.findAnnotation(method, GrowToolHint.class); + /* + * Annotation 검색 + * + * 1순위 : Interface + * 2순위 : 구현체 + */ + ToolAnnotationMetadata annotationMetadata = findToolAnnotationMetadata(targetClass, method); - if (functionAnnotation != null) { - String rawSubToolName = functionAnnotation.name(); - String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() - ? mcpProperties.getNamespace() + "_" + rawSubToolName - : rawSubToolName; - // register 값은 외부 Gateway 전송이 아니라 Tool 메타데이터 호환 필드로만 유지합니다. - boolean isRegister = hintAnnotation == null || hintAnnotation.register(); - - ToolMetadata meta = new ToolMetadata(); - meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString()); - String displayName = functionAnnotation.title().isEmpty() ? functionAnnotation.name() : functionAnnotation.title(); - meta.setDisplayName(displayName); - meta.setName(subToolName); - meta.setSemver("1.0.0"); - meta.setTimeoutMillis(5000L); - meta.setEnabled(true); - meta.setDescription(functionAnnotation.description()); - meta.setCategoryKey(hintAnnotation == null || hintAnnotation.categoryKey().isBlank() - ? "common" : hintAnnotation.categoryKey()); - meta.setIntegrationType("REST"); - meta.setMciServiceId(hintAnnotation != null ? hintAnnotation.mappingId() : ""); - meta.setPodUrl(podUrl); - meta.setModuleName(applicationName); - meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName); - - meta.setVisible(true); - meta.setIsRegistered(isRegister); - 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 prompts = new HashMap<>(); - meta.setActionPrompts(prompts); - - if (method.getParameterCount() > 0) { - try { - Class paramType = method.getParameterTypes()[0]; - // TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction - Map finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType); - meta.setParametersSchema(finalSchema); - Map outputSchema = toolSchemaResolver.resolveOutput( - functionAnnotation, method.getReturnType(), hintAnnotation); - if (!outputSchema.isEmpty()) { - meta.setOutputSchema(outputSchema); - } - } catch (Exception e) { - log.error("Failed to generate schema for {}", subToolName, e); - } - } - - enrichWithDefinition(meta, rawSubToolName, hintAnnotation); - - allScannedTools.add(meta); - log.info(" [ToolScanner] 도구 메타데이터 생성: {} (isRegistered: {})", meta.getUid(), isRegister); + if (annotationMetadata == null) { + continue; } + + McpTool functionAnnotation = annotationMetadata.mcpTool(); + GrowToolHint hintAnnotation = annotationMetadata.growToolHint(); + + String rawSubToolName = functionAnnotation.name(); + /* + * Namespace 적용 + */ + String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() ? mcpProperties.getNamespace() + "_" + rawSubToolName : rawSubToolName; + + /* + * register 값은 외부 Gateway 전송이 아니라 + * Tool 메타데이터 호환 필드로 유지 + */ + boolean isRegister = hintAnnotation == null || hintAnnotation.register(); + ToolMetadata meta = new ToolMetadata(); + meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString()); + String displayName = functionAnnotation.title().isEmpty() ? functionAnnotation.name() : functionAnnotation.title(); + meta.setDisplayName(displayName); + meta.setName(subToolName); + meta.setSemver("1.0.0"); + meta.setTimeoutMillis(5000L); + meta.setEnabled(true); + meta.setDescription(functionAnnotation.description()); + + meta.setCategoryKey(hintAnnotation == null || hintAnnotation.categoryKey().isBlank() ? "common" : hintAnnotation.categoryKey()); + meta.setIntegrationType("REST"); + meta.setMciServiceId(hintAnnotation != null ? hintAnnotation.mappingId() : ""); + + meta.setPodUrl(podUrl); + meta.setModuleName(applicationName); + + meta.setEndpoint(podUrl.replaceAll("/+$", "") + "/mcp/" + subToolName); + + meta.setVisible(true); + meta.setIsRegistered(isRegister); + meta.setRequiresApproval(hintAnnotation != null && hintAnnotation.requiresApproval()); + + /* + * MCP standard hint + */ + 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 prompts = new HashMap<>(); + + meta.setActionPrompts(prompts); + + /* + * Request / Response Schema 생성 + * + * Annotation은 Interface에서 읽어오지만 + * 실제 Method의 Parameter/Return Type은 + * 구현체 Method를 기준으로 사용 + */ + if (method.getParameterCount() > 0) { + + try { + + Class paramType = method.getParameterTypes()[0]; + Map finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType); + meta.setParametersSchema(finalSchema); + Map outputSchema = toolSchemaResolver.resolveOutput(functionAnnotation, method.getReturnType(), hintAnnotation); + if (!outputSchema.isEmpty()) { + meta.setOutputSchema(outputSchema); + } + + } catch (Exception e) { + + log.error("Failed to generate schema for {}", subToolName, e); + } + } + + /* + * YML Tool Definition 병합 + */ + enrichWithDefinition(meta, rawSubToolName, hintAnnotation); + allScannedTools.add(meta); + log.info(" [ToolScanner] 도구 메타데이터 생성: " + "name={}, class={}#{}, " + "isRegistered={}", subToolName, targetClass.getSimpleName(), method.getName(), isRegister); } } + + log.info(" [ToolScanner] 총 {}개 Tool 메타데이터 생성 완료", allScannedTools.size()); + } + + /** + * @McpTool / @GrowToolHint 검색 + *

+ * 우선순위 + *

+ * 1. Interface + * 2. Implementation + */ + private ToolAnnotationMetadata findToolAnnotationMetadata(Class targetClass, Method method) { + + /* + * ======================================== + * STEP 1. + * Interface부터 검색 + * ======================================== + */ + for (Class interfaceClass : ClassUtils.getAllInterfacesForClassAsSet(targetClass)) { + + Method interfaceMethod = ReflectionUtils.findMethod(interfaceClass, method.getName(), method.getParameterTypes()); + if (interfaceMethod == null) { + continue; + } + + McpTool mcpTool = AnnotationUtils.findAnnotation(interfaceMethod, McpTool.class); + if (mcpTool == null) { + continue; + } + GrowToolHint growToolHint = AnnotationUtils.findAnnotation(interfaceMethod, GrowToolHint.class); + log.debug(" [ToolScanner] Interface @McpTool 발견: " + "{}#{} -> {}", interfaceClass.getSimpleName(), interfaceMethod.getName(), mcpTool.name()); + + return new ToolAnnotationMetadata(mcpTool, growToolHint); + } + + /* + * ======================================== + * STEP 2. + * Interface에서 찾지 못한 경우에만 + * 구현체 Method 검색 + * ======================================== + */ + McpTool mcpTool = AnnotationUtils.findAnnotation(method, McpTool.class); + + if (mcpTool == null) { + return null; + } + + GrowToolHint growToolHint = AnnotationUtils.findAnnotation(method, GrowToolHint.class); + + log.debug(" [ToolScanner] Implementation @McpTool 발견: " + "{}#{} -> {}", targetClass.getSimpleName(), method.getName(), mcpTool.name()); + + return new ToolAnnotationMetadata(mcpTool, growToolHint); } private void enrichWithDefinition(ToolMetadata meta, String rawToolName, GrowToolHint hintAnnotation) { + if (toolDefinitionRepository == null) { return; } - toolDefinitionRepository.findByName(rawToolName) - .ifPresent(definition -> applyDefinition(meta, definition, hintAnnotation)); + + toolDefinitionRepository.findByName(rawToolName).ifPresent(definition -> applyDefinition(meta, definition, hintAnnotation)); } private void applyDefinition(ToolMetadata meta, ToolDefinition definition, GrowToolHint hintAnnotation) { + meta.setDisplayName(definition.displayName()); meta.setSemver(definition.version()); meta.setCategoryKey(definition.categoryKey()); @@ -187,17 +283,26 @@ public class ToolRegistryHeartbeatSender { meta.setDestructiveHint(definition.destructive()); meta.setIdempotentHint(definition.idempotent()); boolean explicitInputResource = hintAnnotation != null && !hintAnnotation.inputSchemaResource().isBlank(); + boolean explicitOutputResource = hintAnnotation != null && !hintAnnotation.outputSchemaResource().isBlank(); + if (!explicitInputResource) { meta.setParametersSchema(definition.parametersSchema()); } + if (!explicitOutputResource && definition.outputSchema() != null && !definition.outputSchema().isEmpty()) { meta.setOutputSchema(definition.outputSchema()); } + meta.setTags(definition.tags()); meta.setMciServiceId(definition.legacyInterfaceId()); meta.setRequiredEnvKeys(definition.requiredEnvKeys()); meta.setOwnerOrg(definition.ownerOrg()); } -} + /** + * Annotation 검색 결과 + */ + private record ToolAnnotationMetadata(McpTool mcpTool, GrowToolHint growToolHint) { + } +} \ No newline at end of file