feat: unify MCP tool execution and chat callbacks
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m2s

This commit is contained in:
jade
2026-08-09 16:11:46 +09:00
parent 7c9077dbd1
commit 03203a9912
14 changed files with 266 additions and 257 deletions

View File

@@ -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<String> 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()

View File

@@ -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:

View File

@@ -85,11 +85,11 @@
</div>
<div class="flex items-center gap-4">
<select id="model-select" class="bg-[#1e2128] text-slate-300 text-xs px-3 py-1.5 rounded-lg border border-white/10 focus:outline-none focus:border-emerald-500/50 cursor-pointer">
<option value="inclusionai/ling-3.0-flash:free" selected>Ling 3.0 Flash ⭐ (기본 - OpenRouter)</option>
<option value="inclusionai/ling-3.0-flash:free">Ling 3.0 Flash (무료 - 현재 제공 상태에 따라 제한될 수 있음)</option>
<option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option>
<option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option>
<option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron Nano 30B (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free">Cohere North Mini (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free" selected>Cohere North Mini (기본 · 무료 - OpenRouter)</option>
<option value="gemini-flash-latest">Gemini 1.5 Flash (직접 API - Google)</option>
</select>

View File

@@ -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);
}
}

View File

@@ -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<String, Object> 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<String, String> 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<String, Object> arguments, String requestId) {
if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null;
try {
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
List<Error> 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<String, Object> 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<String, Object> 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<String, Object> 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) { }
}

View File

@@ -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<String, String> headers) {
public ToolExecutionResult {
headers = headers == null ? Map.of() : Map.copyOf(headers);
}
public boolean isSuccess() {
return statusCode >= 200 && statusCode < 300;
}
}

View File

@@ -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<String, Object> 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<String, Object>) definitions : Map.of());
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
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<ToolMetadata> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "404");
Map<String, Object> 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<String, Object> inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, hint, paramType);
List<Error> errors = toolArgumentSchemaValidator.validate(inputSchema, arguments);
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
List<String> errorMessages = new ArrayList<>();
for (Error validationError : errors) {
errorMessages.add(validationError.getMessage());
}
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "422");
Map<String, Object> 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<String, Object> outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType(), outputHint);
if (!outputSchema.isEmpty()) {
List<Error> outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult);
if (!outputErrors.isEmpty()) {
log.error("[Tool] Output schema validation failed. tool={}, errors={}",
functionName, outputErrors);
Map<String, Object> 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<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "500");
Map<String, Object> 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());
}
}

View File

@@ -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));
}
}
}

View File

@@ -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<String, Object> 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; }
}
}

View File

@@ -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;

View File

@@ -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"));
}
}
}