feat: propagate MCP request headers to tools
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m28s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m28s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user