feat: propagate MCP request headers to tools
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m28s

This commit is contained in:
jade
2026-07-30 17:10:15 +09:00
parent b5ff930556
commit 60055dd9ed
7 changed files with 169 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@@ -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<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool(toolName, null, null, null, arguments);
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();
try {

View File

@@ -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<String, Object> arguments) {
String finalRequestId = headerRequestId;

View File

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

View File

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