From 2a611482c16a438fac556c917ae8a06a61aaa1b4 Mon Sep 17 00:00:00 2001 From: jade Date: Tue, 18 Aug 2026 09:49:34 +0900 Subject: [PATCH] fix: align tool headers and remove gateway registration --- .../http/component/AxhubHttpComponent.java | 8 +- .../dap/lib/mcp/McpRequestHeaderFilter.java | 11 +- .../dap/lib/mcp/McpRequestHeaders.java | 9 +- .../dap/lib/mcp/McpToolExecutionService.java | 23 ++-- .../lib/mcp/ToolRegistryHeartbeatSender.java | 91 +------------- .../dap/lib/mcp/config/SwaggerConfig.java | 10 +- .../dap/lib/mcp/config/WebConfig.java | 4 +- .../lib/mcp/security/ApiKeyInterceptor.java | 4 +- .../presentation/BusinessToolController.java | 11 +- .../resources/static/tool-test-console.html | 12 +- .../component/AxhubHttpComponentTest.java | 67 +++++++++++ .../manifest/ToolManifestServiceTest.java | 5 +- .../dap/lib/mcp/ToolExecutionServiceTest.java | 31 ++++- .../mcp/ToolRegistryHeartbeatSenderTest.java | 111 ++++++++++++++---- .../WebConfigToolExecutionSecurityTest.java | 82 +++++++++++++ .../mcp/security/ApiKeyInterceptorTest.java | 26 ++++ .../dap/lib/util/ToolScaffolderTest.java | 52 ++++---- .../mcc/mcp/McpRequestHeaderFilterTest.java | 69 +++++------ ...inessToolControllerHeaderContractTest.java | 36 +++--- 19 files changed, 416 insertions(+), 246 deletions(-) rename dap-was-lib/src/test/java/io/shinhanlife/dap/{mcc => lib}/manifest/ToolManifestServiceTest.java (95%) create mode 100644 dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/config/WebConfigToolExecutionSecurityTest.java create mode 100644 dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptorTest.java diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponent.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponent.java index dccf1fe5c..d1b9e330c 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponent.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponent.java @@ -92,9 +92,11 @@ public class AxhubHttpComponent { if (inbound == null) { header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST); } else { - putIfPresent(header, "trace-id", inbound.traceId()); - putIfPresent(header, "request-id", inbound.requestId()); - putIfPresent(header, "X-USER-ID", inbound.encryptedEmployeeId()); + putIfPresent(header, "x-request-id", inbound.requestId()); + putIfPresent(header, "guid", inbound.guid()); + putIfPresent(header, "mcp-session-id", inbound.mcpSessionId()); + putIfPresent(header, "employee-no", inbound.employeeNo()); + putIfPresent(header, "virtual-employee-no", inbound.virtualEmployeeNo()); } header.setReadTimeout(timeout == 0 ? defaultReadTimeout() : timeout); return header; diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaderFilter.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaderFilter.java index aae2654f4..b7c8d8962 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaderFilter.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaderFilter.java @@ -21,14 +21,15 @@ public class McpRequestHeaderFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { McpRequestHeaderContext.set(new McpRequestHeaders( - request.getHeader("X-Request-Id"), - request.getHeader("trace-id"), - request.getHeader("request-id"), - request.getHeader("employee-id"))); + request.getHeader("x-request-id"), + request.getHeader("guid"), + request.getHeader("mcp-session-id"), + request.getHeader("employee-no"), + request.getHeader("virtual-employee-no"))); try { filterChain.doFilter(request, response); } finally { McpRequestHeaderContext.clear(); } } -} \ No newline at end of file +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaders.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaders.java index 3eb87c766..a4a0a3e27 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaders.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/McpRequestHeaders.java @@ -2,8 +2,9 @@ package io.shinhanlife.dap.lib.mcp; /** Optional request headers propagated from an MCP HTTP request to a Tool invocation. */ public record McpRequestHeaders( - String headerRequestId, - String traceId, String requestId, - String encryptedEmployeeId) { -} \ No newline at end of file + String guid, + String mcpSessionId, + String employeeNo, + String virtualEmployeeNo) { +} 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 index 6b0c6cfbb..2a59d3976 100644 --- 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 @@ -25,33 +25,34 @@ public class McpToolExecutionService { 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); + String guid = requestHeaders == null ? null : requestHeaders.guid(); + String mcpSessionId = requestHeaders == null ? null : requestHeaders.mcpSessionId(); + log.info("[Tool] IN - guid: {}, x-request-id: {}, tool: {}", guid, requestId, functionName); McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName); if (resolvedTool == null) { - return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, headerRequestId); + return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, requestId); } - ToolExecutionResult validationFailure = validateInput(resolvedTool, arguments, headerRequestId); + ToolExecutionResult validationFailure = validateInput(resolvedTool, arguments, requestId); if (validationFailure != null) { return validationFailure; } try { Object methodResult = invoke(resolvedTool, convertArgument(resolvedTool.method(), arguments)); - ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, headerRequestId); + ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, requestId); 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); + if (requestId != null) headers.put("x-request-id", requestId); + if (guid != null) headers.put("guid", guid); + if (mcpSessionId != null) headers.put("mcp-session-id", mcpSessionId); + log.info("[Tool] OUT - guid: {}, x-request-id: {}, tool: {}", guid, 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); + return error(502, "TOOL_ERROR", "Tool execution failed", requestId); } } @@ -98,4 +99,4 @@ public class McpToolExecutionService { if (requestId != null) body.put("request_id", requestId); return new ToolExecutionResult(statusCode, body, 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 c3deb4ac6..9fa6ca14e 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 @@ -24,9 +24,6 @@ import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.lib.metadata.ToolDefinition; import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository; import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; @@ -39,29 +36,20 @@ import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.AnnotationUtils; -import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import org.springframework.util.ClassUtils; -import org.springframework.web.client.RestClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @Slf4j @Component -@Configuration -@EnableScheduling @ConditionalOnBean(McpToolExecutionService.class) public class ToolRegistryHeartbeatSender { private final ApplicationContext applicationContext; private final ObjectMapper objectMapper; private final McpProperties mcpProperties; - private final RestClient restClient = RestClient.create(); private final ToolSchemaResolver toolSchemaResolver; private final ToolDefinitionRepository toolDefinitionRepository; @@ -81,23 +69,18 @@ public class ToolRegistryHeartbeatSender { this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null); } - @Value("${axhub.gateway.url:http://localhost:8081}") - private String gatewayUrl; - @Value("${axhub.tool.url:http://localhost:8080}") private String podUrl; @Value("${spring.application.name:}") private String applicationName; - private List registeredTools = new ArrayList<>(); - @Getter private List allScannedTools = new ArrayList<>(); @PostConstruct public void init() { - log.info(" [HeartbeatSender] 초기화 시작. Gateway URL: {}, Pod URL: {}", gatewayUrl, podUrl); + log.info(" [ToolScanner] 초기화 시작. Pod URL: {}", podUrl); scanAndBuildMetadata(); } @@ -111,19 +94,12 @@ public class ToolRegistryHeartbeatSender { GrowToolHint hintAnnotation = AnnotationUtils.findAnnotation(method, GrowToolHint.class); if (functionAnnotation != null) { - String baseName = functionAnnotation.name(); String rawSubToolName = functionAnnotation.name(); String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() ? mcpProperties.getNamespace() + "_" + rawSubToolName : rawSubToolName; - // @GrowToolHint인 Tool은 메타데이터 조회에는 남기되, - // Gateway 등록 및 heartbeat 전송 대상에서는 제외합니다. - // ToolHint가 없는 기존 Tool은 이전 동작과 동일하게 등록합니다. + // register 값은 외부 Gateway 전송이 아니라 Tool 메타데이터 호환 필드로만 유지합니다. boolean isRegister = hintAnnotation == null || hintAnnotation.register(); - if (!isRegister) { - log.info(" [HeartbeatSender] '{}' Tool is excluded from Gateway registration because . (tool name: {})", - baseName, subToolName); - } ToolMetadata meta = new ToolMetadata(); meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString()); @@ -181,11 +157,8 @@ public class ToolRegistryHeartbeatSender { enrichWithDefinition(meta, rawSubToolName, hintAnnotation); - if (isRegister) { - registeredTools.add(meta); - } allScannedTools.add(meta); - log.info(" [HeartbeatSender] 도구 메타데이터 생성: {} (isRegistered: {})", meta.getUid(), isRegister); + log.info(" [ToolScanner] 도구 메타데이터 생성: {} (isRegistered: {})", meta.getUid(), isRegister); } } } @@ -227,62 +200,4 @@ public class ToolRegistryHeartbeatSender { meta.setOwnerOrg(definition.ownerOrg()); } - @EventListener(ApplicationReadyEvent.class) - public void registerAllTools() { - if (registeredTools.isEmpty()) return; - - new Thread(() -> { - for (ToolMetadata tool : registeredTools) { - registerTool(tool); - } - }, "McpToolRegistrationThread").start(); - } - - @PreDestroy - public void deregisterAllTools() { - if (registeredTools.isEmpty()) return; - - for (ToolMetadata tool : registeredTools) { - try { - restClient.post() - .uri(gatewayUrl + "/mcp/api/v1/registry/deregister") - .header("Content-Type", "application/json") - .body(tool.getUid()) - .retrieve() - .toBodilessEntity(); - log.info(" [HeartbeatSender] 툴 삭제(Deregister) 성공: {}", tool.getUid()); - } catch (Exception ex) { - log.warn(" [HeartbeatSender] 툴 삭제 실패: {}", ex.getMessage()); - } - } - } - - private void registerTool(ToolMetadata tool) { - int maxRetries = 12; - int delayMs = 10000; - for (int i = 0; i < maxRetries; i++) { - try { - restClient.post() - .uri(gatewayUrl + "/mcp/api/v1/registry/register") - .header("Content-Type", "application/json") - .body(tool) - .retrieve() - .toBodilessEntity(); - log.info(" [HeartbeatSender] 툴 등록 성공: {}", tool.getUid()); - return; - } catch (Exception ex) { - if (i == maxRetries - 1) { - log.error(" [HeartbeatSender] 툴 등록 최종 실패 ({}회 재시도): {}", maxRetries, ex.getMessage()); - } else { - log.warn(" [HeartbeatSender] 툴 등록 실패, {}초 후 재시도... ({}/{}): {}", delayMs/1000, i+1, maxRetries, ex.getMessage()); - try { - Thread.sleep(delayMs); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - break; - } - } - } - } - } } diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/SwaggerConfig.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/SwaggerConfig.java index 5c2e22c96..670980957 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/SwaggerConfig.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/SwaggerConfig.java @@ -34,14 +34,14 @@ public class SwaggerConfig { .description("AI Agent와 신한라이프 내부망(EIMS/EAI)을 연결하는 Adapter Gateway API 문서입니다.")) .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8080").description("Adapter Pod (8080)")) .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8081").description("Gateway Pod (8081)")) - // 전역적으로 X-API-KEY 보안 설정을 Swagger UI에 추가합니다. - .addSecurityItem(new SecurityRequirement().addList("X-API-KEY")) + // DAPMS가 Tool Service 호출에 사용하는 API Key 헤더를 Swagger UI에도 추가합니다. + .addSecurityItem(new SecurityRequirement().addList("X-Tool-Server-API-Key")) .components(new Components() - .addSecuritySchemes("X-API-KEY", + .addSecuritySchemes("X-Tool-Server-API-Key", new SecurityScheme() - .name("X-API-KEY") + .name("X-Tool-Server-API-Key") .type(SecurityScheme.Type.APIKEY) .in(SecurityScheme.In.HEADER) .description("헤더에 API Key를 입력해주세요. "))); } -} \ No newline at end of file +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/WebConfig.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/WebConfig.java index 9e3cfc8b4..d4e3d1f78 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/WebConfig.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/config/WebConfig.java @@ -33,7 +33,7 @@ public class WebConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { // 2. 인터셉터 등록 및 검사할 URL 패턴 지정 registry.addInterceptor(apiKeyInterceptor) - .addPathPatterns("/rpc/**", "/mcp/api/v1/**") // /rpc/, /mcp/api/v1/ 로 시작하는 모든 API는 API Key 검사 수행! + .addPathPatterns("/rpc/**", "/mcp/**") .excludePathPatterns( "/test/**", "/health", "/error", "/mcp/api/v1/admin/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", // Swagger UI 경로는 인증 제외 @@ -51,4 +51,4 @@ public class WebConfig implements WebMvcConfigurer { .exposedHeaders("Mcp-Session-Id") .allowCredentials(true); } -} \ No newline at end of file +} diff --git a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptor.java b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptor.java index 89fb3db58..de2b65280 100644 --- a/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptor.java +++ b/dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptor.java @@ -39,7 +39,7 @@ public class ApiKeyInterceptor implements HandlerInterceptor { return true; } - String apiKey = request.getHeader("X-API-KEY"); + String apiKey = request.getHeader("X-Tool-Server-API-Key"); Map validApiKeys = securityProperties.getApiKeys(); // 2. 만약 프로퍼티에 API Key가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다. @@ -77,4 +77,4 @@ public class ApiKeyInterceptor implements HandlerInterceptor { // 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다. MDC.remove("tenantId"); } -} \ No newline at end of file +} 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 3926a8795..ea7da8311 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 @@ -34,14 +34,15 @@ public class BusinessToolController { @PostMapping("/mcp/{name}") public ResponseEntity executeDynamicTool( @PathVariable("name") String functionName, - @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId, - @RequestHeader(value = "trace-id", required = false) String traceId, - @RequestHeader(value = "request-id", required = false) String requestId, - @RequestHeader(value = "employee-id", required = false) String encryptedEmployeeId, + @RequestHeader(value = "x-request-id", required = false) String requestId, + @RequestHeader(value = "guid", required = false) String guid, + @RequestHeader(value = "mcp-session-id", required = false) String mcpSessionId, + @RequestHeader(value = "employee-no", required = false) String employeeNo, + @RequestHeader(value = "virtual-employee-no", required = false) String virtualEmployeeNo, @RequestBody(required = false) Map arguments) { ToolExecutionResult result = toolExecutionService.execute( functionName, - new McpRequestHeaders(headerRequestId, traceId, requestId, encryptedEmployeeId), + new McpRequestHeaders(requestId, guid, mcpSessionId, employeeNo, virtualEmployeeNo), arguments); ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode()); result.headers().forEach(response::header); diff --git a/dap-was-lib/src/main/resources/static/tool-test-console.html b/dap-was-lib/src/main/resources/static/tool-test-console.html index 85644db9e..bd5dbd370 100644 --- a/dap-was-lib/src/main/resources/static/tool-test-console.html +++ b/dap-was-lib/src/main/resources/static/tool-test-console.html @@ -69,7 +69,7 @@

2. 요청 JSON

필수값과 형식은 Tool의 inputSchema 기준입니다. MCI·외부 연동 Tool은 업무에 맞는 테스트 데이터를 입력한 후 저장하세요.
-

3. 실행 결과

대기-trace-id: -request-id: -
Tool을 선택하고 실행하세요.
+

3. 실행 결과

대기-guid: -x-request-id: -
Tool을 선택하고 실행하세요.
@@ -257,11 +257,11 @@ const fallback = `/mcp/${encodeURIComponent(tool.name)}`; try { const endpoint = new URL(tool.endpoint || fallback, window.location.origin); return endpoint.origin === window.location.origin ? `${endpoint.pathname}${endpoint.search}` : fallback; } catch (_) { return fallback; } } - function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'trace-id: -'; $('requestId').textContent = 'request-id: -'; } + function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'guid: -'; $('requestId').textContent = 'x-request-id: -'; } async function execute(tool = state.selected, body = null) { if (!tool) throw new Error('실행할 Tool을 선택하세요.'); - const payload = body || parseArguments(); const trace = requestId(), request = requestId(), started = performance.now(); + const payload = body || parseArguments(); const guid = requestId(), request = requestId(), session = requestId(), started = performance.now(); $('executeButton').disabled = true; $('httpStatus').textContent = '실행 중'; $('httpStatus').className = 'badge'; try { let response; @@ -269,13 +269,13 @@ const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: tool.name, arguments: payload }, id: Date.now() }; response = await fetch('/mcp/api/v1/tools/call', { method: 'POST', - headers: { 'Content-Type':'application/json', 'trace-id':trace, 'request-id':request, 'X-Request-Id':request }, + headers: { 'Content-Type':'application/json', 'guid':guid, 'x-request-id':request, 'mcp-session-id':session }, body: JSON.stringify(reqPayload) }); } else { response = await fetch(endpointFor(tool), { method: 'POST', - headers: { 'Content-Type':'application/json', 'trace-id':trace, 'request-id':request, 'X-Request-Id':request }, + headers: { 'Content-Type':'application/json', 'guid':guid, 'x-request-id':request, 'mcp-session-id':session }, body: JSON.stringify(payload) }); } @@ -290,7 +290,7 @@ const elapsed = Math.round(performance.now() - started); $('httpStatus').textContent = `HTTP ${displayStatus}`; $('httpStatus').className = `badge ${isOk ? 'ok' : 'fail'}`; - $('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `trace-id: ${response.headers.get('trace-id') || trace}`; $('requestId').textContent = `request-id: ${response.headers.get('request-id') || request}`; + $('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `guid: ${response.headers.get('guid') || guid}`; $('requestId').textContent = `x-request-id: ${response.headers.get('x-request-id') || request}`; let displayData = data; if (isGatewayMode && data && typeof data === 'object') { if (data.result && data.result.result) { diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponentTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponentTest.java index 074a0b292..476d172d9 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponentTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/integration/http/component/AxhubHttpComponentTest.java @@ -9,8 +9,13 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat import com.fasterxml.jackson.databind.ObjectMapper; import io.shinhanlife.dap.lib.config.GlowCommunicationProperties; +import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext; +import io.shinhanlife.dap.lib.mcp.McpRequestHeaders; import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent; +import java.lang.reflect.Method; +import java.util.Arrays; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import org.springframework.test.web.client.MockRestServiceServer; @@ -62,6 +67,68 @@ class AxhubHttpComponentTest { assertThat(response.status()).isEqualTo("OK"); server.verify(); } + + @Test + void forwardsDapmsHeadersToTheConfiguredHttpService() throws Exception { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + AxhubHttpProperties properties = new AxhubHttpProperties(); + properties.setApiList(List.of(new AxhubHttpProperties.ApiDefinition( + "status", "https://api.example.test", "/v1", HttpMethod.POST, "application/json", false))); + AxhubHttpComponent component = new AxhubHttpComponent( + new GlowHttpComponent(builder), new ObjectMapper(), new GlowCommunicationProperties(), properties); + + server.expect(requestTo("https://api.example.test/v1")) + .andExpect(header("x-request-id", "request-1")) + .andExpect(header("guid", "guid-1")) + .andExpect(header("mcp-session-id", "session-1")) + .andExpect(header("employee-no", "ENC(employee)")) + .andExpect(header("virtual-employee-no", "ENC(virtual)")) + .andRespond(withSuccess("{\"status\":\"OK\"}", APPLICATION_JSON)); + + setRequestHeaders(headers(Map.of( + "requestId", "request-1", + "guid", "guid-1", + "mcpSessionId", "session-1", + "employeeNo", "ENC(employee)", + "virtualEmployeeNo", "ENC(virtual)", + "headerRequestId", "request-1", + "traceId", "guid-1", + "encryptedEmployeeId", "ENC(employee)"))); + try { + assertThat(component.call("status", Map.of(), SampleResponse.class).status()).isEqualTo("OK"); + server.verify(); + } finally { + clearRequestHeaders(); + } + } + + private McpRequestHeaders headers(Map values) { + try { + Class[] types = Arrays.stream(McpRequestHeaders.class.getRecordComponents()) + .map(component -> component.getType()) + .toArray(Class[]::new); + Object[] arguments = Arrays.stream(McpRequestHeaders.class.getRecordComponents()) + .map(component -> values.get(component.getName())) + .toArray(); + return McpRequestHeaders.class.getDeclaredConstructor(types).newInstance(arguments); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } + } + + private void setRequestHeaders(McpRequestHeaders headers) throws Exception { + Method method = McpRequestHeaderContext.class.getDeclaredMethod("set", McpRequestHeaders.class); + method.setAccessible(true); + method.invoke(null, headers); + } + + private void clearRequestHeaders() throws Exception { + Method method = McpRequestHeaderContext.class.getDeclaredMethod("clear"); + method.setAccessible(true); + method.invoke(null); + } + record SampleResponse(String status) { } } 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/lib/manifest/ToolManifestServiceTest.java similarity index 95% rename from dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java rename to dap-was-lib/src/test/java/io/shinhanlife/dap/lib/manifest/ToolManifestServiceTest.java index fec3156fb..85de539b6 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/lib/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/lib/mcp/ToolExecutionServiceTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolExecutionServiceTest.java index 7b3fd5779..d4e115a62 100644 --- 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 @@ -8,6 +8,7 @@ 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 java.util.Arrays; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -27,11 +28,35 @@ class McpToolExecutionServiceTest { 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")); + headers(Map.of( + "requestId", "request-1", + "guid", "guid-1", + "mcpSessionId", "session-1", + "employeeNo", "employee-1", + "virtualEmployeeNo", "virtual-1", + "headerRequestId", "request-1", + "traceId", "guid-1", + "encryptedEmployeeId", "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")); + assertEquals("request-1", result.headers().get("x-request-id")); + assertEquals("guid-1", result.headers().get("guid")); + assertEquals("session-1", result.headers().get("mcp-session-id")); + } + + private McpRequestHeaders headers(Map values) { + try { + Class[] types = Arrays.stream(McpRequestHeaders.class.getRecordComponents()) + .map(component -> component.getType()) + .toArray(Class[]::new); + Object[] arguments = Arrays.stream(McpRequestHeaders.class.getRecordComponents()) + .map(component -> values.get(component.getName())) + .toArray(); + return McpRequestHeaders.class.getDeclaredConstructor(types).newInstance(arguments); + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } } private McpToolExecutionService serviceWith(Object toolBean) { diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSenderTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSenderTest.java index f91639d08..a4d094798 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSenderTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSenderTest.java @@ -5,52 +5,117 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; -import io.shinhanlife.dap.lib.annotation.GrowToolHint; +import com.sun.net.httpserver.HttpServer; import io.shinhanlife.dap.lib.config.McpProperties; import io.shinhanlife.dap.lib.util.ToolSchemaResolver; -import java.lang.reflect.Field; -import java.util.List; +import jakarta.annotation.PreDestroy; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.time.Duration; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpTool; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationContext; +import org.springframework.context.event.EventListener; class ToolRegistryHeartbeatSenderTest { @Test - void excludesRegisterFalseToolFromGatewayRegistrationTargets() throws Exception { + void applicationReadyDoesNotPushToolsToGateway() throws Exception { + try (GatewayProbe gateway = new GatewayProbe()) { + ToolRegistryHeartbeatSender sender = senderWithOneTool(gateway.url()); + + sender.init(); + invokeLifecycleMethods(sender, EventListener.class); + + assertThat(gateway.receivedRequestWithin(Duration.ofMillis(300))).isFalse(); + } + } + + @Test + void shutdownDoesNotDeregisterToolsFromGateway() throws Exception { + try (GatewayProbe gateway = new GatewayProbe()) { + ToolRegistryHeartbeatSender sender = senderWithOneTool(gateway.url()); + + sender.init(); + invokeLifecycleMethods(sender, PreDestroy.class); + + assertThat(gateway.receivedRequestWithin(Duration.ofMillis(300))).isFalse(); + } + } + + private ToolRegistryHeartbeatSender senderWithOneTool(String gatewayUrl) throws Exception { ApplicationContext applicationContext = mock(ApplicationContext.class); - when(applicationContext.getBeansOfType(Object.class)).thenReturn(Map.of("disabledTool", new DisabledTool())); + when(applicationContext.getBeansOfType(Object.class)).thenReturn(Map.of("tool", new SampleTool())); ToolRegistryHeartbeatSender sender = new ToolRegistryHeartbeatSender( applicationContext, new ObjectMapper(), new McpProperties(), mock(ToolSchemaResolver.class)); - + setFieldIfPresent(sender, "gatewayUrl", gatewayUrl); setField(sender, "podUrl", "http://localhost:8084"); + return sender; + } - sender.init(); - - assertThat(sender.getAllScannedTools()).singleElement() - .extracting(tool -> tool.getIsRegistered()) - .isEqualTo(false); - assertThat(registeredTools(sender)).isEmpty(); + private void invokeLifecycleMethods(Object target, Class annotationType) throws Exception { + for (Method method : target.getClass().getDeclaredMethods()) { + if (method.getAnnotation(annotationType) == null) { + continue; + } + if (method.getParameterCount() == 0) { + method.invoke(target); + } else if (method.getParameterTypes()[0] == ApplicationReadyEvent.class) { + method.invoke(target, mock(ApplicationReadyEvent.class)); + } + } } private void setField(Object target, String name, Object value) throws Exception { - Field field = target.getClass().getDeclaredField(name); + var field = target.getClass().getDeclaredField(name); field.setAccessible(true); field.set(target, value); } - @SuppressWarnings("unchecked") - private List registeredTools(ToolRegistryHeartbeatSender sender) throws Exception { - Field field = ToolRegistryHeartbeatSender.class.getDeclaredField("registeredTools"); - field.setAccessible(true); - return (List) field.get(sender); + + private void setFieldIfPresent(Object target, String name, Object value) throws Exception { + try { + setField(target, name, value); + } catch (NoSuchFieldException ignored) { + // The desired implementation has no Gateway registration configuration. + } } - static class DisabledTool { - - @McpTool(name = "test_disabled_tool") - @GrowToolHint + static class SampleTool { + @McpTool(name = "test_sample_tool") void execute() { } } -} \ No newline at end of file + + private static final class GatewayProbe implements AutoCloseable { + private final HttpServer server; + private final CountDownLatch requestReceived = new CountDownLatch(1); + + private GatewayProbe() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requestReceived.countDown(); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + server.start(); + } + + private String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private boolean receivedRequestWithin(Duration timeout) throws InterruptedException { + return requestReceived.await(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/config/WebConfigToolExecutionSecurityTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/config/WebConfigToolExecutionSecurityTest.java new file mode 100644 index 000000000..ea89552d8 --- /dev/null +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/config/WebConfigToolExecutionSecurityTest.java @@ -0,0 +1,82 @@ +package io.shinhanlife.dap.lib.mcp.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.shinhanlife.dap.lib.mcp.security.ApiKeyInterceptor; +import io.shinhanlife.dap.lib.mcp.security.SecurityProperties; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +class WebConfigToolExecutionSecurityTest { + private AnnotationConfigWebApplicationContext context; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + context.register(TestConfiguration.class); + context.refresh(); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @AfterEach + void tearDown() { + context.close(); + } + + @Test + void protectsTheRestToolExecutionPathWithTheDapmsApiKey() throws Exception { + mockMvc.perform(post("/mcp/sample").contentType("application/json").content("{}")) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post("/mcp/sample") + .header("X-Tool-Server-API-Key", "tool-server-key") + .contentType("application/json") + .content("{}")) + .andExpect(status().isOk()); + } + + @Configuration + @EnableWebMvc + @Import(WebConfig.class) + static class TestConfiguration { + @Bean + SecurityProperties securityProperties() { + SecurityProperties properties = new SecurityProperties(); + properties.setApiKeys(Map.of("tool-server-key", "dapms")); + return properties; + } + + @Bean + ApiKeyInterceptor apiKeyInterceptor(SecurityProperties properties) { + return new ApiKeyInterceptor(properties); + } + + @Bean + TestController testController() { + return new TestController(); + } + } + + @RestController + static class TestController { + @PostMapping("/mcp/{name}") + Map execute(@PathVariable String name) { + return Map.of("name", name); + } + } +} diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptorTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptorTest.java new file mode 100644 index 000000000..d0ca486a4 --- /dev/null +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/security/ApiKeyInterceptorTest.java @@ -0,0 +1,26 @@ +package io.shinhanlife.dap.lib.mcp.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class ApiKeyInterceptorTest { + + @Test + void authenticatesWithTheToolServerApiKeyHeaderSentByDapms() throws Exception { + SecurityProperties properties = new SecurityProperties(); + properties.setApiKeys(Map.of("tool-server-key", "dapms")); + ApiKeyInterceptor interceptor = new ApiKeyInterceptor(properties); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Tool-Server-API-Key", "tool-server-key"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + assertThat(allowed).isTrue(); + assertThat(request.getAttribute("tenantId")).isEqualTo("dapms"); + } +} diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java index db22407f7..49b35299c 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java @@ -31,13 +31,13 @@ class ToolScaffolderTest { new ToolScaffolder.ToolMethodDefinition( "CustomerGuidance", "searchGuidance", "CTMNILO00007", "Customer guidance", "Search guidance", "cmm", "MCI", false, "NILD", null, - List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", "C001", true)), - List.of(new ToolScaffolder.FieldDefinition("guidanceStatus", "String", "Guidance status", "OPEN", false)), null), + List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", List.of("C001"), "", true)), + List.of(new ToolScaffolder.FieldDefinition("guidanceStatus", "String", "Guidance status", List.of("OPEN"), "", false)), null), new ToolScaffolder.ToolMethodDefinition( "CustomerContract", "searchContract", "CTMCNT00001", "Customer contract", "Search contract", "cmm", "MCI", false, "CNTD", null, - List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", "C001", true)), - List.of(new ToolScaffolder.FieldDefinition("contractStatus", "String", "Contract status", "ACTIVE", false)), null))); + List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", List.of("C001"), "", true)), + List.of(new ToolScaffolder.FieldDefinition("contractStatus", "String", "Contract status", List.of("ACTIVE"), "", false)), null))); Path sourceRoot = root.resolve("dap-was-customer/src/main/java/io/shinhanlife/dap/mcc"); String useCase = Files.readString(sourceRoot.resolve("biz/cmm/usecase/CustomerUseCase.java")); @@ -59,8 +59,8 @@ class ToolScaffolderTest { new ToolScaffolder.ToolMethodDefinition( "EmployeeSearch", "searchEmployee", null, "Employee search", "Search employee", "smp", "HTTP", false, null, "employee-search", - List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "Employee number", "10001", true)), - List.of(new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong", false)), null))); + List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "Employee number", List.of("10001"), "", true)), + List.of(new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", List.of("Hong"), "", false)), null))); Path glowConfig = root.resolve("dap-was-http/src/main/resources/glow/application-glow-local.yml"); assertTrue(Files.exists(glowConfig)); @@ -71,13 +71,13 @@ class ToolScaffolderTest { void generatesEnumAndListFieldsInDtoSchemaAndMockResponse() throws Exception { String moduleName = root.resolve("dap-was-claim").toString(); List inputFields = List.of( - new ToolScaffolder.FieldDefinition("claimStatus", "Enum", "Claim status", "OPEN", true, + new ToolScaffolder.FieldDefinition("claimStatus", "Enum", "Claim status", List.of("OPEN"), "", true, List.of("OPEN", "CLOSED"), null, List.of()), - new ToolScaffolder.FieldDefinition("customerIds", "List", "Customer IDs", "C001", false, + new ToolScaffolder.FieldDefinition("customerIds", "List", "Customer IDs", List.of("C001"), "", false, List.of(), "String", List.of())); List outputFields = List.of( - new ToolScaffolder.FieldDefinition("guidanceItems", "List", "Guidance items", "", false, - List.of(), "Object", List.of(new ToolScaffolder.FieldDefinition("status", "String", "Status", "OPEN", true)))); + new ToolScaffolder.FieldDefinition("guidanceItems", "List", "Guidance items", List.of(), "", false, + List.of(), "Object", List.of(new ToolScaffolder.FieldDefinition("status", "String", "Status", List.of("OPEN"), "", true)))); ToolScaffolder.scaffold("claim search", "CLM0001", "Claim search", "cmm", "MCI", moduleName, "tester", "2026.08.12", false, "CLM1", null, null, inputFields, outputFields); @@ -107,8 +107,8 @@ class ToolScaffolderTest { "분석 데이터를 조회하기 위한 도구로 다양한 분석 결과를 제공합니다.", "cmm", "MCI", moduleName, "테스터", "2026.08.12", false, null, null, null, - List.of(new ToolScaffolder.FieldDefinition("query", "String", "조회 조건", "계약 분석", true)), - List.of(new ToolScaffolder.FieldDefinition("analysisResult", "String", "분석 결과", "정상", false))); + List.of(new ToolScaffolder.FieldDefinition("query", "String", "조회 조건", List.of("계약 분석"), "", true)), + List.of(new ToolScaffolder.FieldDefinition("analysisResult", "String", "분석 결과", List.of("정상"), "", false))); Path sourceRoot = root.resolve("dap-was-korean/src/main/java/io/shinhanlife/dap/mcc"); Path useCase = sourceRoot.resolve("biz/cmm/usecase/AnalysisDataQueryUseCase.java"); @@ -146,7 +146,7 @@ class ToolScaffolderTest { ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", "사번으로 재직 중인 직원을 조회한다.", "smp", "HTTP", moduleName, "tester", "2026.08.12", false, null, null, null, - List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "09860000", true)), + List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", List.of("09860000"), "", true)), List.of(), "employee"); Path definition = root.resolve("dap-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); @@ -175,7 +175,7 @@ class ToolScaffolderTest { ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", "직원을 조회한다.", "smp", "HTTP", moduleName, "tester", "2026.08.12", false, null, null, null, - List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "10001", true)), + List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", List.of("10001"), "", true)), List.of(), "employee", options); Path definition = root.resolve("dap-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); @@ -247,7 +247,7 @@ class ToolScaffolderTest { String request = Files.readString(requestPath); assertTrue(request.contains("import io.swagger.v3.oas.annotations.media.Schema;")); - assertTrue(request.contains("@Schema(description = \"Search query\", example = \"example\")")); + assertTrue(request.contains("@Schema(description = \"Search query (예시: example)\", example = \"example\")")); assertTrue(request.contains("private String query;")); assertFalse(request.contains("McpToolParam")); @@ -261,10 +261,10 @@ class ToolScaffolderTest { void generatesMciToolFromDeclaredInputAndOutputFields() throws Exception { String moduleName = root.resolve("dap-was-pay").toString(); List inputFields = List.of( - new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", "EMP10001", true), - new ToolScaffolder.FieldDefinition("page", "Integer", "Page number", "1", false)); + new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", List.of("EMP10001"), "", true), + new ToolScaffolder.FieldDefinition("page", "Integer", "Page number", List.of("1"), "", false)); List outputFields = List.of( - new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong Gildong", true)); + new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", List.of("Hong Gildong"), "", true)); ToolScaffolder.scaffold("search hr", "SHEARCH_01", "HR search", "pay", "MCI", moduleName, "tester", "2026.08.09", true, "DFAG", null, null, inputFields, outputFields); @@ -291,7 +291,7 @@ class ToolScaffolderTest { void generatesMockResponseAndUnitTestSkeletonFromOutputFields() throws Exception { String moduleName = root.resolve("dap-was-cus").toString(); List outputFields = List.of( - new ToolScaffolder.FieldDefinition("status", "String", "Claim status", "RECEIVED", true)); + new ToolScaffolder.FieldDefinition("status", "String", "Claim status", List.of("RECEIVED"), "", true)); ToolScaffolder.scaffold("claim search", "CLM0001", "Claim search", "cmm", "MCI", moduleName, "tester", "2026.08.10", true, null, null, null, List.of(), outputFields); @@ -318,12 +318,12 @@ class ToolScaffolderTest { void generatesDtoPackageAndRemovesDuplicateResponseFields() throws Exception { String moduleName = root.resolve("dap-was-http").toString(); List outputFields = List.of( - new ToolScaffolder.FieldDefinition("resultCode", "String", "API result", "SUCCESS", true), - new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong Gildong", false), - new ToolScaffolder.FieldDefinition("employeeName", "String", "Duplicate name", "Duplicate", false)); + new ToolScaffolder.FieldDefinition("resultCode", "String", "API result", List.of("SUCCESS"), "", true), + new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", List.of("Hong Gildong"), "", false), + new ToolScaffolder.FieldDefinition("employeeName", "String", "Duplicate name", List.of("Duplicate"), "", false)); List inputFields = List.of( - new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", "EMP10001", true)); + new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", List.of("EMP10001"), "", true)); String resultLog = ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "Employee search", "smp", "HTTP", moduleName, "tester", "2026.08.11", false, null, null, null, inputFields, outputFields); @@ -434,10 +434,10 @@ class ToolScaffolderTest { void generatesObjectListAsNestedInnerClassWithoutSeparateItemSource() throws Exception { String moduleName = root.resolve("dap-was-inner-list").toString(); List fields = List.of( - new ToolScaffolder.FieldDefinition("data", "List", "activity data", "", false, + new ToolScaffolder.FieldDefinition("data", "List", "activity data", List.of(), "", false, List.of(), "Object", List.of( - new ToolScaffolder.FieldDefinition("date", "String", "date", "2026-08-13", true), - new ToolScaffolder.FieldDefinition("users", "Integer", "users", "10", false)))); + new ToolScaffolder.FieldDefinition("date", "String", "date", List.of("2026-08-13"), "", true), + new ToolScaffolder.FieldDefinition("users", "Integer", "users", List.of("10"), "", false)))); ToolScaffolder.scaffold("ga activity status", "GA001", "GA status", "GA status", "ana", "HTTP", moduleName, "tester", "2026.08.13", false, null, null, null, List.of(), fields); 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 159c27ff7..e37c3ecca 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 @@ -1,17 +1,13 @@ package io.shinhanlife.dap.mcc.mcp; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.McpSyncServer; -import io.shinhanlife.dap.lib.mcp.*; -import java.lang.reflect.Method; +import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext; +import io.shinhanlife.dap.lib.mcp.McpRequestHeaderFilter; +import io.shinhanlife.dap.lib.mcp.McpRequestHeaders; +import java.lang.reflect.RecordComponent; +import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; @@ -20,41 +16,34 @@ import org.springframework.mock.web.MockHttpServletResponse; class McpRequestHeaderFilterTest { @Test - void capturesOptionalMcpHeadersOnlyForTheCurrentRequest() throws Exception { + void capturesDapmsHeadersOnlyForTheCurrentRequest() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); - request.addHeader("X-Request-Id", "gateway-request-id"); - request.addHeader("trace-id", "trace-001"); - request.addHeader("request-id", "tool-request-001"); - request.addHeader("employee-id", "encrypted-employee-id"); + request.addHeader("x-request-id", "request-001"); + request.addHeader("guid", "guid-001"); + request.addHeader("mcp-session-id", "session-001"); + request.addHeader("employee-no", "ENC(employee)"); + request.addHeader("virtual-employee-no", "ENC(virtual)"); - new McpRequestHeaderFilter().doFilter(request, new MockHttpServletResponse(), (req, res) -> { - McpRequestHeaders headers = McpRequestHeaderContext.current(); - assertEquals("gateway-request-id", headers.headerRequestId()); - assertEquals("trace-001", headers.traceId()); - assertEquals("tool-request-001", headers.requestId()); - assertEquals("encrypted-employee-id", headers.encryptedEmployeeId()); - }); + new McpRequestHeaderFilter().doFilter(request, new MockHttpServletResponse(), (req, res) -> + assertThat(asMap(McpRequestHeaderContext.current())).containsExactly( + Map.entry("requestId", "request-001"), + Map.entry("guid", "guid-001"), + Map.entry("mcpSessionId", "session-001"), + Map.entry("employeeNo", "ENC(employee)"), + Map.entry("virtualEmployeeNo", "ENC(virtual)"))); assertNull(McpRequestHeaderContext.current()); } - @Test - 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), service, new ObjectMapper()); - - Method invoke = ToolPodMcpToolSynchronizer.class.getDeclaredMethod( - "invoke", String.class, McpRequestHeaders.class, Map.class); - invoke.setAccessible(true); - invoke.invoke(synchronizer, "sampleTool", - headers, - Map.of("key", "value")); - - verify(service).execute("sampleTool", headers, Map.of("key", "value")); + private Map asMap(McpRequestHeaders headers) { + try { + Map values = new LinkedHashMap<>(); + for (RecordComponent component : McpRequestHeaders.class.getRecordComponents()) { + values.put(component.getName(), component.getAccessor().invoke(headers)); + } + return values; + } catch (ReflectiveOperationException exception) { + throw new AssertionError(exception); + } } } diff --git a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java index 165b6aebc..84c3e9776 100644 --- a/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java +++ b/dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java @@ -1,32 +1,30 @@ package io.shinhanlife.dap.mcc.presentation; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.assertj.core.api.Assertions.assertThat; import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.Map; - +import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.web.bind.annotation.RequestHeader; class BusinessToolControllerHeaderContractTest { @Test - void encryptedEmployeeIdHeaderIsOptional() throws Exception { - Method method = BusinessToolController.class.getDeclaredMethod( - "executeDynamicTool", - String.class, - String.class, - String.class, - String.class, - String.class, - Map.class); + void receivesTheHeadersForwardedByDapms() { + Method method = Arrays.stream(BusinessToolController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("executeDynamicTool")) + .findFirst() + .orElseThrow(); - Parameter employeeIdParameter = method.getParameters()[4]; - RequestHeader requestHeader = employeeIdParameter.getAnnotation(RequestHeader.class); + List headerNames = Arrays.stream(method.getParameters()) + .map(parameter -> parameter.getAnnotation(RequestHeader.class)) + .filter(java.util.Objects::nonNull) + .peek(header -> assertThat(header.required()).isFalse()) + .map(RequestHeader::value) + .toList(); - assertEquals("employee-id", requestHeader.value()); - assertFalse(requestHeader.required()); + assertThat(headerNames).containsExactly( + "x-request-id", "guid", "mcp-session-id", "employee-no", "virtual-employee-no"); } -} \ No newline at end of file +}