Compare commits
4 Commits
9d16b8ef3a
...
b3908f771f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3908f771f | ||
| 628925dce9 | |||
|
|
1bf09705d1 | ||
|
|
60055dd9ed |
17
build.gradle
17
build.gradle
@@ -49,3 +49,20 @@ subprojects {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
def toolCoreProject = project(':dap-tool-core')
|
||||
|
||||
tasks.register('validateMcpToolNames', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Checks duplicate @McpFunction names across all Tool modules before packaging.'
|
||||
dependsOn toolCoreProject.tasks.named('classes')
|
||||
classpath = toolCoreProject.sourceSets.main.runtimeClasspath
|
||||
mainClass.set('io.shinhanlife.dap.lib.validation.McpToolNameValidationRunner')
|
||||
args rootProject.projectDir.absolutePath
|
||||
}
|
||||
|
||||
subprojects {
|
||||
tasks.matching { it.name == 'bootJar' }.configureEach {
|
||||
dependsOn rootProject.tasks.named('validateMcpToolNames')
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@
|
||||
buffer = buffer.substring(eventEnd + 2);
|
||||
const dataLines = event.split('\n')
|
||||
.filter(line => line.startsWith('data:'))
|
||||
.map(line => line.substring(5).replace(/^ /, ''));
|
||||
.map(line => line.substring(5));
|
||||
|
||||
if (dataLines.length > 0) onData(dataLines.join('\n'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.shinhanlife.dap.lib.validation;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Gradle entry point for validating unique MCP Tool names before packaging. */
|
||||
public final class McpToolNameValidationRunner {
|
||||
|
||||
private McpToolNameValidationRunner() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length != 1) {
|
||||
throw new IllegalArgumentException("Usage: McpToolNameValidationRunner <project-root>");
|
||||
}
|
||||
validate(Path.of(args[0]));
|
||||
}
|
||||
|
||||
static void validate(Path projectRoot) {
|
||||
McpToolNameValidator.assertUnique(projectRoot);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,15 @@ class McpToolNameValidatorTest {
|
||||
assertTrue(exception.getMessage().contains("dap-tool-second"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationRunnerRejectsDuplicateMcpFunctionNamesBeforePackaging() throws IOException {
|
||||
writeToolSource("dap-tool-first", "FirstTool.java", "first", "send_sms");
|
||||
writeToolSource("dap-tool-second", "SecondTool.java", "second", "send_sms");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> McpToolNameValidationRunner.validate(temporaryRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsCurrentProjectToolNames() {
|
||||
assertDoesNotThrow(() -> McpToolNameValidator.assertUnique(findProjectRoot()));
|
||||
|
||||
@@ -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