From 03203a991278b7ecf8e935f33ef9974211cd2be3 Mon Sep 17 00:00:00 2001 From: jade Date: Sun, 9 Aug 2026 16:11:46 +0900 Subject: [PATCH] feat: unify MCP tool execution and chat callbacks --- .../dap/mcg/presentation/ChatController.java | 6 +- .../src/main/resources/application.yml | 3 +- .../src/main/resources/static/chat.html | 4 +- .../lib/config/ToolSchemaConfiguration.java | 6 + .../dap/lib/mcp/McpToolExecutionService.java | 136 +++++++++++ .../dap/lib/mcp/ToolExecutionResult.java | 15 ++ .../lib/mcp/ToolPodMcpToolSynchronizer.java | 27 +-- .../lib/mcp/ToolRegistryHeartbeatSender.java | 3 +- .../ToolArgumentSchemaValidator.java | 2 - .../presentation/BusinessToolController.java | 221 ++---------------- .../config/ToolSchemaConfigurationTest.java | 12 +- .../dap/lib/mcp/ToolExecutionServiceTest.java | 60 +++++ .../mcc/manifest/ToolManifestServiceTest.java | 5 +- .../mcc/mcp/McpRequestHeaderFilterTest.java | 23 +- 14 files changed, 266 insertions(+), 257 deletions(-) create mode 100644 dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolExecutionService.java create mode 100644 dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolExecutionResult.java create mode 100644 dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolExecutionServiceTest.java diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java index c676aa0d..acbc6c39 100644 --- a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ChatController.java @@ -75,9 +75,9 @@ public class ChatController { } } - String selectedModel = request.getOrDefault("model", "gemini-flash-latest").trim(); + String selectedModel = request.getOrDefault("model", "cohere/north-mini-code:free").trim(); if (selectedModel.isEmpty()) { - selectedModel = "gemini-flash-latest"; + selectedModel = "cohere/north-mini-code:free"; } // 1. gemini-flash-latest 선택 시 OpenRouter의 Google Gemma 4 모델로 라우팅 @@ -93,7 +93,7 @@ public class ChatController { Flux responseStream = activeChatClient.prompt() .user(message) - .tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools() + .toolCallbacks(callbacks.toArray(new ToolCallback[0])) .options(org.springframework.ai.openai.OpenAiChatOptions.builder() .model(selectedModel).build()) .stream() diff --git a/dap-gateway/src/main/resources/application.yml b/dap-gateway/src/main/resources/application.yml index 3cb3e3a5..0c7afd8e 100644 --- a/dap-gateway/src/main/resources/application.yml +++ b/dap-gateway/src/main/resources/application.yml @@ -13,8 +13,9 @@ spring: api-key: ${OPENROUTER_API_KEY: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} base-url: https://openrouter.ai/api/v1 chat: + completions-path: /chat/completions options: - model: google/gemma-4-31b-it:free + model: cohere/north-mini-code:free temperature: 0.3 server: diff --git a/dap-gateway/src/main/resources/static/chat.html b/dap-gateway/src/main/resources/static/chat.html index 70b21172..f082500c 100644 --- a/dap-gateway/src/main/resources/static/chat.html +++ b/dap-gateway/src/main/resources/static/chat.html @@ -85,11 +85,11 @@
diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/config/ToolSchemaConfiguration.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/config/ToolSchemaConfiguration.java index 0364a5e5..d0f8e2ac 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/config/ToolSchemaConfiguration.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/config/ToolSchemaConfiguration.java @@ -2,6 +2,7 @@ package io.shinhanlife.dap.lib.config; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.lib.util.ToolSchemaResolver; +import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,4 +16,9 @@ public class ToolSchemaConfiguration { public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) { return new ToolSchemaResolver(objectMapper); } + + @Bean + public ToolArgumentSchemaValidator toolArgumentSchemaValidator(ObjectMapper objectMapper) { + return new ToolArgumentSchemaValidator(objectMapper); + } } diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolExecutionService.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolExecutionService.java new file mode 100644 index 00000000..bb395f5b --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpToolExecutionService.java @@ -0,0 +1,136 @@ +package io.shinhanlife.dap.lib.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.Error; +import io.shinhanlife.dap.lib.annotation.ToolHint; +import io.shinhanlife.dap.lib.config.McpProperties; +import io.shinhanlife.dap.lib.util.ToolSchemaResolver; +import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.stereotype.Service; + +/** Executes a discovered Tool independently from its HTTP or MCP transport. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class McpToolExecutionService { + + private final ApplicationContext applicationContext; + private final ObjectMapper objectMapper; + private final McpProperties mcpProperties; + private final ToolArgumentSchemaValidator toolArgumentSchemaValidator; + private final ToolSchemaResolver toolSchemaResolver; + + public ToolExecutionResult execute(String functionName, McpRequestHeaders requestHeaders, + Map arguments) { + String headerRequestId = requestHeaders == null ? null : requestHeaders.headerRequestId(); + String traceId = requestHeaders == null ? null : requestHeaders.traceId(); + String requestId = requestHeaders == null ? null : requestHeaders.requestId(); + log.info("[Tool] IN - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName); + + ResolvedTool resolvedTool = findTool(functionName); + if (resolvedTool == null) { + return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, headerRequestId); + } + ToolExecutionResult validationFailure = validateInput(resolvedTool, arguments, headerRequestId); + if (validationFailure != null) { + return validationFailure; + } + try { + Object methodResult = invoke(resolvedTool, convertArgument(resolvedTool.method(), arguments)); + ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, headerRequestId); + if (outputFailure != null) { + return outputFailure; + } + Map headers = new HashMap<>(); + if (traceId != null) headers.put("trace-id", traceId); + if (requestId != null) headers.put("request-id", requestId); + log.info("[Tool] OUT - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName); + return new ToolExecutionResult(200, methodResult, headers); + } catch (Exception error) { + log.error("[Tool] Tool execution failed. tool={}", functionName, error); + return error(502, "TOOL_ERROR", "Tool execution failed", headerRequestId); + } + } + + private ResolvedTool findTool(String functionName) { + 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 && matches(functionName, annotation.name())) { + return new ResolvedTool(bean, findInvocableMethod(bean, declaredMethod), annotation, + AnnotationUtils.findAnnotation(declaredMethod, ToolHint.class)); + } + } + } + return null; + } + + private boolean matches(String requestedName, String baseName) { + String namespace = mcpProperties.getNamespace(); + String expectedName = namespace != null && !namespace.isEmpty() ? namespace + "_" + baseName : baseName; + return expectedName.equals(requestedName) || baseName.equals(requestedName); + } + + private Method findInvocableMethod(Object bean, Method declaredMethod) { + try { + return bean.getClass().getMethod(declaredMethod.getName(), declaredMethod.getParameterTypes()); + } catch (NoSuchMethodException ignored) { + return declaredMethod; + } + } + + private ToolExecutionResult validateInput(ResolvedTool tool, Map arguments, String requestId) { + if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null; + try { + Map schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]); + List errors = toolArgumentSchemaValidator.validate(schema, arguments); + return errors.isEmpty() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId); + } catch (Exception error) { + log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error); + return null; + } + } + + private Object convertArgument(Method method, Map arguments) { + if (method.getParameterCount() == 0 || arguments == null || Map.class.isAssignableFrom(method.getParameterTypes()[0])) return arguments; + return objectMapper.convertValue(arguments, method.getParameterTypes()[0]); + } + + private Object invoke(ResolvedTool tool, Object argument) throws Exception { + return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument); + } + + private ToolExecutionResult validateOutput(ResolvedTool tool, Object methodResult, String requestId) { + try { + Map outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint()); + if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).isEmpty()) { + return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId); + } + } catch (Exception error) { + log.error("[Tool] Output schema validation failed unexpectedly. tool={}", tool.annotation().name(), error); + } + return null; + } + + private ToolExecutionResult error(int statusCode, String code, String message, String requestId) { + Map body = new HashMap<>(); + body.put("code", code); + body.put("message", message); + body.put("details", Map.of("status", Integer.toString(statusCode))); + if (requestId != null) body.put("request_id", requestId); + return new ToolExecutionResult(statusCode, body, Map.of()); + } + + private record ResolvedTool(Object bean, Method method, McpTool annotation, ToolHint hint) { } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolExecutionResult.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolExecutionResult.java new file mode 100644 index 00000000..461c3f42 --- /dev/null +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolExecutionResult.java @@ -0,0 +1,15 @@ +package io.shinhanlife.dap.lib.mcp; + +import java.util.Map; + +/** Protocol-neutral result of invoking one Tool Pod business tool. */ +public record ToolExecutionResult(int statusCode, Object body, Map headers) { + + public ToolExecutionResult { + headers = headers == null ? Map.of() : Map.copyOf(headers); + } + + public boolean isSuccess() { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java index b223f0ce..9d327e5a 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java @@ -5,34 +5,29 @@ import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import io.shinhanlife.dap.mcc.dto.ToolMetadata; -import io.shinhanlife.dap.mcc.presentation.BusinessToolController; -import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender; -import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext; -import io.shinhanlife.dap.lib.mcp.McpRequestHeaders; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; /** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */ @Component -@ConditionalOnBean(BusinessToolController.class) +@ConditionalOnBean(McpToolExecutionService.class) public class ToolPodMcpToolSynchronizer { private final McpSyncServer mcpServer; private final ToolRegistryHeartbeatSender heartbeatSender; - private final BusinessToolController businessToolController; + private final McpToolExecutionService toolExecutionService; private final ObjectMapper objectMapper; public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender, - BusinessToolController businessToolController, ObjectMapper objectMapper) { + McpToolExecutionService toolExecutionService, ObjectMapper objectMapper) { this.mcpServer = mcpServer; this.heartbeatSender = heartbeatSender; - this.businessToolController = businessToolController; + this.toolExecutionService = toolExecutionService; this.objectMapper = objectMapper; } @@ -62,15 +57,9 @@ public class ToolPodMcpToolSynchronizer { private McpSchema.CallToolResult invoke(String toolName, McpRequestHeaders requestHeaders, Map arguments) { - ResponseEntity response = businessToolController.executeDynamicTool( - toolName, - requestHeaders == null ? null : requestHeaders.headerRequestId(), - requestHeaders == null ? null : requestHeaders.traceId(), - requestHeaders == null ? null : requestHeaders.requestId(), - requestHeaders == null ? null : requestHeaders.encryptedEmployeeId(), - arguments); - boolean failed = !response.getStatusCode().is2xxSuccessful(); - Object body = response.getBody(); + ToolExecutionResult result = toolExecutionService.execute(toolName, requestHeaders, arguments); + boolean failed = !result.isSuccess(); + Object body = result.body(); try { return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body)) .structuredContent(body).isError(failed).build(); @@ -101,4 +90,4 @@ public class ToolPodMcpToolSynchronizer { schema.get("definitions") instanceof Map definitions ? (Map) definitions : Map.of()); } -} \ 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 ba14d0a8..997bb3b5 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 @@ -44,14 +44,13 @@ import org.springframework.util.ClassUtils; import org.springframework.web.client.RestClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import io.shinhanlife.dap.mcc.presentation.BusinessToolController; @Slf4j @Component @Configuration @EnableScheduling @RequiredArgsConstructor -@ConditionalOnBean(BusinessToolController.class) +@ConditionalOnBean(McpToolExecutionService.class) public class ToolRegistryHeartbeatSender { private final ApplicationContext applicationContext; diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolArgumentSchemaValidator.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolArgumentSchemaValidator.java index c138ab07..46e54ea6 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolArgumentSchemaValidator.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolArgumentSchemaValidator.java @@ -8,10 +8,8 @@ import com.networknt.schema.SchemaRegistry; import com.networknt.schema.SpecificationVersion; import java.util.List; import java.util.Map; -import org.springframework.stereotype.Component; /** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */ -@Component public class ToolArgumentSchemaValidator { private final ObjectMapper objectMapper; diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java index 602a4a78..3926a879 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java @@ -1,42 +1,14 @@ package io.shinhanlife.dap.mcc.presentation; - -/** - * @package io.shinhanlife.dap.mcc.presentation - * @className BusinessToolController - * @description AX HUB 시스템 처리 클래스 - * @author 0986406 - * @create 2026.09.01 - *
- * ---------- 개정이력 ----------
- * 수정일      수정자    수정내용
- * ---------- -------- ---------------------------
- * 2026.09.01  0986406    최초생성
- * 
- * 
- */ -import com.fasterxml.jackson.databind.ObjectMapper; -import com.networknt.schema.Error; -import org.springaicommunity.mcp.annotation.McpTool; -import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; -import io.shinhanlife.dap.lib.config.McpProperties; -import io.shinhanlife.dap.mcc.dto.ToolMetadata; +import io.shinhanlife.dap.lib.mcp.McpRequestHeaders; +import io.shinhanlife.dap.lib.mcp.ToolExecutionResult; +import io.shinhanlife.dap.lib.mcp.McpToolExecutionService; import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender; -import io.shinhanlife.dap.lib.util.ToolSchemaResolver; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; +import io.shinhanlife.dap.mcc.dto.ToolMetadata; import java.util.List; import java.util.Map; -import java.util.Set; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.aop.support.AopUtils; -import org.springframework.context.ApplicationContext; -import org.springframework.core.annotation.AnnotationUtils; import org.springframework.http.ResponseEntity; -import org.springframework.util.ClassUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -45,26 +17,20 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Slf4j +/** Legacy REST adapter for Tool Pod execution. */ @RestController @RequestMapping("/") @RequiredArgsConstructor public class BusinessToolController { - private final ApplicationContext applicationContext; - private final ObjectMapper objectMapper; - private final McpProperties mcpProperties; private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender; - private final ToolArgumentSchemaValidator toolArgumentSchemaValidator; + private final McpToolExecutionService toolExecutionService; - private final ToolSchemaResolver toolSchemaResolver; - // 내부 조회용 로컬 Tool 목록 엔드포인트 @GetMapping("/mcp/api/v1/tools/local") public List getLocalTools() { return toolRegistryHeartbeatSender.getAllScannedTools(); } - // 순수 REST 기반 동적 라우팅 엔드포인트 @PostMapping("/mcp/{name}") public ResponseEntity executeDynamicTool( @PathVariable("name") String functionName, @@ -73,173 +39,12 @@ public class BusinessToolController { @RequestHeader(value = "request-id", required = false) String requestId, @RequestHeader(value = "employee-id", required = false) String encryptedEmployeeId, @RequestBody(required = false) Map arguments) { - - String finalRequestId = headerRequestId; - - log.info(" [Tool] IN - trace-id: {}, request-id: {}", traceId, requestId); - log.info(" [Tool] 동적 툴 실행 요청 수신 (함수명): {}", functionName); - if (arguments != null) { - try { - log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(arguments)); - } catch (Exception e) { - log.info(" [Tool] 호출 파라미터: {}", arguments); - } - } - Object targetBean = null; - Method targetMethod = null; - McpTool targetFunctionAnnotation = null; - - // McpTool 어노테이션 기반 조회가 프록시 문제로 누락될 수 있으므로, 전체 빈을 순회하며 @McpTool을 찾습니다. - Map allBeans = applicationContext.getBeansOfType(Object.class); - outerLoop: - for (Object bean : allBeans.values()) { - Class targetClass = AopUtils.getTargetClass(bean); - for (Method targetMethodOfClass : targetClass.getDeclaredMethods()) { - McpTool mcpFunc = AnnotationUtils.findAnnotation(targetMethodOfClass, McpTool.class); - if (mcpFunc != null) { - String baseName = mcpFunc.name(); - String expectedName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() - ? mcpProperties.getNamespace() + "_" + baseName - : baseName; - - if (expectedName.equals(functionName) || baseName.equals(functionName)) { - targetBean = bean; - try { - targetMethod = bean.getClass().getMethod(targetMethodOfClass.getName(), targetMethodOfClass.getParameterTypes()); - } catch (NoSuchMethodException e) { - targetMethod = targetMethodOfClass; - } - targetFunctionAnnotation = mcpFunc; - break outerLoop; - } - } - } - } - - if (targetBean == null || targetMethod == null) { - List availableFunctions = new ArrayList<>(); - for (Object bean : allBeans.values()) { - Class targetCls = AopUtils.getTargetClass(bean); - for (Method m : targetCls.getDeclaredMethods()) { - McpTool func = AnnotationUtils.findAnnotation(m, McpTool.class); - if (func != null) { - String baseName = func.name(); - String expName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() - ? mcpProperties.getNamespace() + "_" + baseName : baseName; - availableFunctions.add(expName + " (in " + targetCls.getSimpleName() + ")"); - } - } - } - log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}. 현재 스캔된 툴 메서드 목록: {}", functionName, availableFunctions); - Map errorDetails = new HashMap<>(); - errorDetails.put("status", "404"); - Map errorBody = new HashMap<>(); - errorBody.put("code", "TOOL_NOT_FOUND"); - errorBody.put("message", "실행할 함수를 찾을 수 없습니다: " + functionName); - errorBody.put("details", errorDetails); - if (finalRequestId != null) errorBody.put("request_id", finalRequestId); - - return ResponseEntity.status(404).body(errorBody); - } - // (기존 차단 로직 제거됨) - - // 2. 파라미터 유효성 검증 (JSON Schema) - if (targetMethod.getParameterCount() > 0) { - Class paramType = targetMethod.getParameterTypes()[0]; - if (!Map.class.isAssignableFrom(paramType)) { - try { - io.shinhanlife.dap.lib.annotation.ToolHint hint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class); - Map inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, hint, paramType); - List errors = toolArgumentSchemaValidator.validate(inputSchema, arguments); - if (!errors.isEmpty()) { - log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors); - List errorMessages = new ArrayList<>(); - for (Error validationError : errors) { - errorMessages.add(validationError.getMessage()); - } - Map errorDetails = new HashMap<>(); - errorDetails.put("status", "422"); - Map errorBody = new HashMap<>(); - errorBody.put("code", "INVALID_PARAM"); - errorBody.put("message", "파라미터 유효성 검증 실패"); - errorBody.put("details", errorDetails); - if (finalRequestId != null) errorBody.put("request_id", finalRequestId); - - return ResponseEntity.status(422).body(errorBody); - } - } catch (Exception e) { - log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage()); - } - } - } - - log.info("[Tool] 리플렉션 직접 실행 -> Method: {}", targetMethod.getName()); - - try { - // 3. DTO 파라미터 자동 매핑 (Map -> DTO) - Object invokeArgument = arguments; - if (targetMethod.getParameterCount() > 0 && arguments != null) { - Class paramType = targetMethod.getParameterTypes()[0]; - if (!Map.class.isAssignableFrom(paramType)) { - invokeArgument = objectMapper.convertValue(arguments, paramType); - log.info("[Tool] DTO 자동 매핑 성공: {}", paramType.getSimpleName()); - } - } - - long startTime = System.currentTimeMillis(); - Object methodResult = null; - if (targetMethod.getParameterCount() == 0) { - methodResult = targetMethod.invoke(targetBean); - } else { - methodResult = targetMethod.invoke(targetBean, invokeArgument); - } - - io.shinhanlife.dap.lib.annotation.ToolHint outputHint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class); - Map outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType(), outputHint); - if (!outputSchema.isEmpty()) { - List outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult); - if (!outputErrors.isEmpty()) { - log.error("[Tool] Output schema validation failed. tool={}, errors={}", - functionName, outputErrors); - Map errorBody = new HashMap<>(); - errorBody.put("code", "INVALID_TOOL_RESPONSE"); - errorBody.put("message", "Tool response does not match its output schema"); - if (finalRequestId != null) { - errorBody.put("request_id", finalRequestId); - } - return ResponseEntity.internalServerError().body(errorBody); - } - } - - long elapsed = System.currentTimeMillis() - startTime; - - // 5. 결과 반환 (순수 REST 응답) - try { - log.info("[Tool -> MCP Gateway] Output Schema Result: {}", objectMapper.writeValueAsString(methodResult)); - } catch (Exception e) { - log.info("[Tool -> MCP Gateway] Output Schema Result: {}", methodResult); - } - - log.info(" [Tool] OUT - trace-id: {}, request-id: {}", traceId, requestId); - - ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok(); - if (traceId != null) responseBuilder.header("trace-id", traceId); - if (requestId != null) responseBuilder.header("request-id", requestId); - - return responseBuilder.body(methodResult); - - } catch (Exception e) { - log.error("[Tool] 리플렉션 실행 중 예외 발생: {}", e.getMessage()); - Map errorDetails = new HashMap<>(); - errorDetails.put("status", "500"); - Map errorBody = new HashMap<>(); - errorBody.put("code", "TOOL_ERROR"); - errorBody.put("message", "Tool execution failed"); - errorDetails.clear(); // Hide details for upstream errors - errorBody.put("details", errorDetails); - if (finalRequestId != null) errorBody.put("request_id", finalRequestId); - - return ResponseEntity.status(502).body(errorBody); - } + ToolExecutionResult result = toolExecutionService.execute( + functionName, + new McpRequestHeaders(headerRequestId, traceId, requestId, encryptedEmployeeId), + arguments); + ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode()); + result.headers().forEach(response::header); + return response.body(result.body()); } } diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/config/ToolSchemaConfigurationTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/config/ToolSchemaConfigurationTest.java index efe77023..c33789f2 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/config/ToolSchemaConfigurationTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/config/ToolSchemaConfigurationTest.java @@ -3,14 +3,20 @@ package io.shinhanlife.dap.lib.config; import static org.junit.jupiter.api.Assertions.assertNotNull; import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; class ToolSchemaConfigurationTest { @Test - void registersToolSchemaResolver() { - ToolSchemaConfiguration configuration = new ToolSchemaConfiguration(); + void providesToolArgumentSchemaValidatorWithoutComponentScanning() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(ObjectMapper.class); + context.register(ToolSchemaConfiguration.class); + context.refresh(); - assertNotNull(configuration.toolSchemaResolver(new ObjectMapper())); + assertNotNull(context.getBean(ToolArgumentSchemaValidator.class)); + } } } diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolExecutionServiceTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolExecutionServiceTest.java new file mode 100644 index 00000000..91f98e32 --- /dev/null +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolExecutionServiceTest.java @@ -0,0 +1,60 @@ +package io.shinhanlife.dap.lib.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dap.lib.annotation.ToolHint; +import io.shinhanlife.dap.lib.config.McpProperties; +import io.shinhanlife.dap.lib.util.ToolSchemaResolver; +import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.context.support.StaticApplicationContext; + +class McpToolExecutionServiceTest { + + @Test + void returnsNotFoundWhenNoToolMatchesTheRequestedName() { + McpToolExecutionService service = serviceWith(new Object()); + ToolExecutionResult result = service.execute("missing.cmm.tool.inquiry", null, Map.of()); + assertEquals(404, result.statusCode()); + assertEquals("TOOL_NOT_FOUND", ((Map) result.body()).get("code")); + } + + @Test + void convertsArgumentsToDtoAndExecutesTheMatchedTool() { + McpToolExecutionService service = serviceWith(new EchoTool()); + ToolExecutionResult result = service.execute("sample.cmm.value.echo", + new McpRequestHeaders("gateway-1", "trace-1", "request-1", "employee-1"), Map.of("value", "hello")); + assertEquals(200, result.statusCode()); + assertEquals("hello", ((Map) result.body()).get("value")); + assertEquals("trace-1", result.headers().get("trace-id")); + assertEquals("request-1", result.headers().get("request-id")); + } + + private McpToolExecutionService serviceWith(Object toolBean) { + ObjectMapper objectMapper = new ObjectMapper(); + StaticApplicationContext context = new StaticApplicationContext(); + context.getBeanFactory().registerSingleton("toolBean", toolBean); + context.refresh(); + return new McpToolExecutionService(context, objectMapper, new McpProperties(), + new ToolArgumentSchemaValidator(objectMapper), new ToolSchemaResolver(objectMapper)); + } + + static class EchoTool { + @McpTool(name = "sample.cmm.value.echo", description = "Echoes a value") + @ToolHint + public Map execute(EchoRequest request) { + return Map.of("value", request.value); + } + } + + static class EchoRequest { + @McpToolParam(description = "Value", required = true) + private String value; + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + } +} diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java index 8d09ca36..6efc38ec 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java @@ -1,4 +1,4 @@ -package io.shinhanlife.dap.mcc.manifest; +package io.shinhanlife.dap.lib.manifest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -6,9 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.lib.config.McpProperties; -import io.shinhanlife.dap.lib.manifest.ToolManifestItem; -import io.shinhanlife.dap.lib.manifest.ToolManifestResponse; -import io.shinhanlife.dap.lib.manifest.ToolManifestService; import io.shinhanlife.dap.mcc.dto.ToolMetadata; import java.util.List; import java.util.Map; diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java index 059174e5..159c27ff 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java @@ -11,12 +11,9 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.shinhanlife.dap.lib.mcp.*; -import io.shinhanlife.dap.mcc.presentation.BusinessToolController; - import java.lang.reflect.Method; import java.util.Map; import org.junit.jupiter.api.Test; -import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -42,22 +39,22 @@ class McpRequestHeaderFilterTest { } @Test - void forwardsCapturedHeadersToBusinessToolExecution() throws Exception { - BusinessToolController controller = mock(BusinessToolController.class); - doReturn(ResponseEntity.ok(Map.of("result", "ok"))) - .when(controller).executeDynamicTool(eq("sampleTool"), eq("gateway-request-id"), eq("trace-001"), - eq("tool-request-001"), eq("encrypted-employee-id"), eq(Map.of("key", "value"))); + void forwardsCapturedHeadersToSharedToolExecutionService() throws Exception { + McpToolExecutionService service = mock(McpToolExecutionService.class); + McpRequestHeaders headers = new McpRequestHeaders( + "gateway-request-id", "trace-001", "tool-request-001", "encrypted-employee-id"); + doReturn(new ToolExecutionResult(200, Map.of("result", "ok"), Map.of())) + .when(service).execute(eq("sampleTool"), eq(headers), eq(Map.of("key", "value"))); ToolPodMcpToolSynchronizer synchronizer = new ToolPodMcpToolSynchronizer( - mock(McpSyncServer.class), mock(ToolRegistryHeartbeatSender.class), controller, new ObjectMapper()); + mock(McpSyncServer.class), mock(ToolRegistryHeartbeatSender.class), service, new ObjectMapper()); Method invoke = ToolPodMcpToolSynchronizer.class.getDeclaredMethod( "invoke", String.class, McpRequestHeaders.class, Map.class); invoke.setAccessible(true); invoke.invoke(synchronizer, "sampleTool", - new McpRequestHeaders("gateway-request-id", "trace-001", "tool-request-001", "encrypted-employee-id"), + headers, Map.of("key", "value")); - verify(controller).executeDynamicTool("sampleTool", "gateway-request-id", "trace-001", - "tool-request-001", "encrypted-employee-id", Map.of("key", "value")); + verify(service).execute("sampleTool", headers, Map.of("key", "value")); } -} \ No newline at end of file +}