diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderContext.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderContext.java new file mode 100644 index 00000000..e3cb547f --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderContext.java @@ -0,0 +1,21 @@ +package io.shinhanlife.dap.mcc.mcp; + +/** Holds optional MCP headers for the lifetime of one HTTP request thread. */ +public final class McpRequestHeaderContext { + private static final ThreadLocal CURRENT_HEADERS = new ThreadLocal<>(); + + private McpRequestHeaderContext() { + } + + public static McpRequestHeaders current() { + return CURRENT_HEADERS.get(); + } + + static void set(McpRequestHeaders headers) { + CURRENT_HEADERS.set(headers); + } + + static void clear() { + CURRENT_HEADERS.remove(); + } +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilter.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilter.java new file mode 100644 index 00000000..273629fb --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilter.java @@ -0,0 +1,34 @@ +package io.shinhanlife.dap.mcc.mcp; + +import java.io.IOException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** Captures optional correlation and employee headers for an MCP HTTP call. */ +@Component +public class McpRequestHeaderFilter extends OncePerRequestFilter { + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().endsWith("/mcp"); + } + + @Override + 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"))); + try { + filterChain.doFilter(request, response); + } finally { + McpRequestHeaderContext.clear(); + } + } +} \ No newline at end of file diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaders.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaders.java new file mode 100644 index 00000000..7ce4fed8 --- /dev/null +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaders.java @@ -0,0 +1,9 @@ +package io.shinhanlife.dap.mcc.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 diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/ToolPodMcpToolSynchronizer.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/ToolPodMcpToolSynchronizer.java index 4fe9ca75..6bbadbc0 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/ToolPodMcpToolSynchronizer.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/mcp/ToolPodMcpToolSynchronizer.java @@ -49,11 +49,18 @@ public class ToolPodMcpToolSynchronizer { .openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build()) .build(); return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool) - .callHandler((context, request) -> invoke(tool.getName(), request.arguments())).build(); + .callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build(); } - private McpSchema.CallToolResult invoke(String toolName, Map arguments) { - ResponseEntity response = businessToolController.executeDynamicTool(toolName, null, null, null, arguments); + 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(); try { diff --git a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java index 64bde2c2..a91233b5 100644 --- a/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java +++ b/dap-tool-core/src/main/java/io/shinhanlife/dap/mcc/presentation/BusinessToolController.java @@ -70,6 +70,7 @@ public class BusinessToolController { @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, @RequestBody(required = false) Map arguments) { String finalRequestId = headerRequestId; diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java new file mode 100644 index 00000000..7d8af77f --- /dev/null +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/mcp/McpRequestHeaderFilterTest.java @@ -0,0 +1,62 @@ +package io.shinhanlife.dap.mcc.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.mcc.presentation.BusinessToolController; +import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender; +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; + +class McpRequestHeaderFilterTest { + + @Test + void capturesOptionalMcpHeadersOnlyForTheCurrentRequest() 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"); + + 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()); + }); + + assertNull(McpRequestHeaderContext.current()); + } + + @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"))); + ToolPodMcpToolSynchronizer synchronizer = new ToolPodMcpToolSynchronizer( + mock(McpSyncServer.class), mock(ToolRegistryHeartbeatSender.class), controller, 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"), + Map.of("key", "value")); + + verify(controller).executeDynamicTool("sampleTool", "gateway-request-id", "trace-001", + "tool-request-001", "encrypted-employee-id", Map.of("key", "value")); + } +} \ No newline at end of file diff --git a/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java b/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java new file mode 100644 index 00000000..165b6aeb --- /dev/null +++ b/dap-tool-core/src/test/java/io/shinhanlife/dap/mcc/presentation/BusinessToolControllerHeaderContractTest.java @@ -0,0 +1,32 @@ +package io.shinhanlife.dap.mcc.presentation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Map; + +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); + + Parameter employeeIdParameter = method.getParameters()[4]; + RequestHeader requestHeader = employeeIdParameter.getAnnotation(RequestHeader.class); + + assertEquals("employee-id", requestHeader.value()); + assertFalse(requestHeader.required()); + } +} \ No newline at end of file