fix: align tool headers and remove gateway registration

This commit is contained in:
jade
2026-08-18 09:49:34 +09:00
parent 00f800aff9
commit 2a611482c1
19 changed files with 416 additions and 246 deletions

View File

@@ -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;

View File

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

View File

@@ -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) {
}
String guid,
String mcpSessionId,
String employeeNo,
String virtualEmployeeNo) {
}

View File

@@ -25,33 +25,34 @@ public class McpToolExecutionService {
public ToolExecutionResult execute(String functionName, McpRequestHeaders requestHeaders,
Map<String, Object> 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<String, String> 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());
}
}
}

View File

@@ -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<ToolMetadata> registeredTools = new ArrayList<>();
@Getter
private List<ToolMetadata> 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;
}
}
}
}
}
}

View File

@@ -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를 입력해주세요. ")));
}
}
}

View File

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

View File

@@ -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<String, String> validApiKeys = securityProperties.getApiKeys();
// 2. 만약 프로퍼티에 API Key가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다.
@@ -77,4 +77,4 @@ public class ApiKeyInterceptor implements HandlerInterceptor {
// 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다.
MDC.remove("tenantId");
}
}
}

View File

@@ -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<String, Object> 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);

View File

@@ -69,7 +69,7 @@
</aside>
<section class="stack">
<section class="card"><h2>2. 요청 JSON</h2><div class="notice">필수값과 형식은 Tool의 inputSchema 기준입니다. MCI·외부 연동 Tool은 업무에 맞는 테스트 데이터를 입력한 후 저장하세요.</div><textarea id="arguments" spellcheck="false" aria-label="요청 JSON"></textarea><div class="buttons" style="margin-top:12px"><button id="saveButton">현재 요청 저장</button><button class="primary" id="executeButton">실행</button></div></section>
<section class="card"><h2>3. 실행 결과</h2><div class="meta"><span class="badge" id="httpStatus">대기</span><span class="badge" id="latency">-</span><span class="badge" id="traceId">trace-id: -</span><span class="badge" id="requestId">request-id: -</span></div><pre class="result" id="result">Tool을 선택하고 실행하세요.</pre></section>
<section class="card"><h2>3. 실행 결과</h2><div class="meta"><span class="badge" id="httpStatus">대기</span><span class="badge" id="latency">-</span><span class="badge" id="traceId">guid: -</span><span class="badge" id="requestId">x-request-id: -</span></div><pre class="result" id="result">Tool을 선택하고 실행하세요.</pre></section>
</section>
</div>
<p class="footer-note">이 화면은 현재 Tool Pod의 <code>/tool-manifest</code><code>/mcp/{toolName}</code>만 사용합니다. 저장된 케이스는 이 브라우저의 localStorage에만 보관됩니다.</p>
@@ -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) {

View File

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

View File

@@ -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;

View File

@@ -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<String, String> 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) {

View File

@@ -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<? extends Annotation> 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() {
}
}
}
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);
}
}
}

View File

@@ -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<String, String> execute(@PathVariable String name) {
return Map.of("name", name);
}
}
}

View File

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

View File

@@ -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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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<ToolScaffolder.FieldDefinition> 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);

View File

@@ -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<String, Object> asMap(McpRequestHeaders headers) {
try {
Map<String, Object> 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);
}
}
}

View File

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