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) { if (inbound == null) {
header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST); header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST);
} else { } else {
putIfPresent(header, "trace-id", inbound.traceId()); putIfPresent(header, "x-request-id", inbound.requestId());
putIfPresent(header, "request-id", inbound.requestId()); putIfPresent(header, "guid", inbound.guid());
putIfPresent(header, "X-USER-ID", inbound.encryptedEmployeeId()); 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); header.setReadTimeout(timeout == 0 ? defaultReadTimeout() : timeout);
return header; return header;

View File

@@ -21,10 +21,11 @@ public class McpRequestHeaderFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
McpRequestHeaderContext.set(new McpRequestHeaders( McpRequestHeaderContext.set(new McpRequestHeaders(
request.getHeader("X-Request-Id"), request.getHeader("x-request-id"),
request.getHeader("trace-id"), request.getHeader("guid"),
request.getHeader("request-id"), request.getHeader("mcp-session-id"),
request.getHeader("employee-id"))); request.getHeader("employee-no"),
request.getHeader("virtual-employee-no")));
try { try {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} finally { } finally {

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. */ /** Optional request headers propagated from an MCP HTTP request to a Tool invocation. */
public record McpRequestHeaders( public record McpRequestHeaders(
String headerRequestId,
String traceId,
String requestId, 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, public ToolExecutionResult execute(String functionName, McpRequestHeaders requestHeaders,
Map<String, Object> arguments) { 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(); 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); McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName);
if (resolvedTool == null) { 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) { if (validationFailure != null) {
return validationFailure; return validationFailure;
} }
try { try {
Object methodResult = invoke(resolvedTool, convertArgument(resolvedTool.method(), arguments)); Object methodResult = invoke(resolvedTool, convertArgument(resolvedTool.method(), arguments));
ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, headerRequestId); ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, requestId);
if (outputFailure != null) { if (outputFailure != null) {
return outputFailure; return outputFailure;
} }
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
if (traceId != null) headers.put("trace-id", traceId); if (requestId != null) headers.put("x-request-id", requestId);
if (requestId != null) headers.put("request-id", requestId); if (guid != null) headers.put("guid", guid);
log.info("[Tool] OUT - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName); 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); return new ToolExecutionResult(200, methodResult, headers);
} catch (Exception error) { } catch (Exception error) {
log.error("[Tool] Tool execution failed. tool={}", functionName, 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);
} }
} }

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.ToolDefinition;
import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository; import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository;
import jakarta.annotation.PostConstruct; 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.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; 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.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable; 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.stereotype.Component;
import org.springframework.util.ClassUtils;
import org.springframework.web.client.RestClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@Slf4j @Slf4j
@Component @Component
@Configuration
@EnableScheduling
@ConditionalOnBean(McpToolExecutionService.class) @ConditionalOnBean(McpToolExecutionService.class)
public class ToolRegistryHeartbeatSender { public class ToolRegistryHeartbeatSender {
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final McpProperties mcpProperties; private final McpProperties mcpProperties;
private final RestClient restClient = RestClient.create();
private final ToolSchemaResolver toolSchemaResolver; private final ToolSchemaResolver toolSchemaResolver;
private final ToolDefinitionRepository toolDefinitionRepository; private final ToolDefinitionRepository toolDefinitionRepository;
@@ -81,23 +69,18 @@ public class ToolRegistryHeartbeatSender {
this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null); this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null);
} }
@Value("${axhub.gateway.url:http://localhost:8081}")
private String gatewayUrl;
@Value("${axhub.tool.url:http://localhost:8080}") @Value("${axhub.tool.url:http://localhost:8080}")
private String podUrl; private String podUrl;
@Value("${spring.application.name:}") @Value("${spring.application.name:}")
private String applicationName; private String applicationName;
private List<ToolMetadata> registeredTools = new ArrayList<>();
@Getter @Getter
private List<ToolMetadata> allScannedTools = new ArrayList<>(); private List<ToolMetadata> allScannedTools = new ArrayList<>();
@PostConstruct @PostConstruct
public void init() { public void init() {
log.info(" [HeartbeatSender] 초기화 시작. Gateway URL: {}, Pod URL: {}", gatewayUrl, podUrl); log.info(" [ToolScanner] 초기화 시작. Pod URL: {}", podUrl);
scanAndBuildMetadata(); scanAndBuildMetadata();
} }
@@ -111,19 +94,12 @@ public class ToolRegistryHeartbeatSender {
GrowToolHint hintAnnotation = AnnotationUtils.findAnnotation(method, GrowToolHint.class); GrowToolHint hintAnnotation = AnnotationUtils.findAnnotation(method, GrowToolHint.class);
if (functionAnnotation != null) { if (functionAnnotation != null) {
String baseName = functionAnnotation.name();
String rawSubToolName = functionAnnotation.name(); String rawSubToolName = functionAnnotation.name();
String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty() String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + rawSubToolName ? mcpProperties.getNamespace() + "_" + rawSubToolName
: rawSubToolName; : rawSubToolName;
// @GrowToolHint인 Tool 메타데이터 조회에는 남기되, // register 값은 외부 Gateway 전송이 아니라 Tool 메타데이터 호환 필드로만 유지합니다.
// Gateway 등록 및 heartbeat 전송 대상에서는 제외합니다.
// ToolHint가 없는 기존 Tool은 이전 동작과 동일하게 등록합니다.
boolean isRegister = hintAnnotation == null || hintAnnotation.register(); 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(); ToolMetadata meta = new ToolMetadata();
meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString()); meta.setUid(UUID.nameUUIDFromBytes(subToolName.getBytes()).toString());
@@ -181,11 +157,8 @@ public class ToolRegistryHeartbeatSender {
enrichWithDefinition(meta, rawSubToolName, hintAnnotation); enrichWithDefinition(meta, rawSubToolName, hintAnnotation);
if (isRegister) {
registeredTools.add(meta);
}
allScannedTools.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()); 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,12 +34,12 @@ public class SwaggerConfig {
.description("AI Agent와 신한라이프 내부망(EIMS/EAI)을 연결하는 Adapter Gateway API 문서입니다.")) .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:8080").description("Adapter Pod (8080)"))
.addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8081").description("Gateway Pod (8081)")) .addServersItem(new io.swagger.v3.oas.models.servers.Server().url("http://localhost:8081").description("Gateway Pod (8081)"))
// 전역적으로 X-API-KEY 보안 설정을 Swagger UI에 추가합니다. // DAPMS가 Tool Service 호출에 사용하는 API Key 헤더를 Swagger UI에 추가합니다.
.addSecurityItem(new SecurityRequirement().addList("X-API-KEY")) .addSecurityItem(new SecurityRequirement().addList("X-Tool-Server-API-Key"))
.components(new Components() .components(new Components()
.addSecuritySchemes("X-API-KEY", .addSecuritySchemes("X-Tool-Server-API-Key",
new SecurityScheme() new SecurityScheme()
.name("X-API-KEY") .name("X-Tool-Server-API-Key")
.type(SecurityScheme.Type.APIKEY) .type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.description("헤더에 API Key를 입력해주세요. "))); .description("헤더에 API Key를 입력해주세요. ")));

View File

@@ -33,7 +33,7 @@ public class WebConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
// 2. 인터셉터 등록 및 검사할 URL 패턴 지정 // 2. 인터셉터 등록 및 검사할 URL 패턴 지정
registry.addInterceptor(apiKeyInterceptor) registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/rpc/**", "/mcp/api/v1/**") // /rpc/, /mcp/api/v1/ 로 시작하는 모든 API는 API Key 검사 수행! .addPathPatterns("/rpc/**", "/mcp/**")
.excludePathPatterns( .excludePathPatterns(
"/test/**", "/health", "/error", "/mcp/api/v1/admin/**", "/test/**", "/health", "/error", "/mcp/api/v1/admin/**",
"/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", // Swagger UI 경로는 인증 제외 "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", // Swagger UI 경로는 인증 제외

View File

@@ -39,7 +39,7 @@ public class ApiKeyInterceptor implements HandlerInterceptor {
return true; return true;
} }
String apiKey = request.getHeader("X-API-KEY"); String apiKey = request.getHeader("X-Tool-Server-API-Key");
Map<String, String> validApiKeys = securityProperties.getApiKeys(); Map<String, String> validApiKeys = securityProperties.getApiKeys();
// 2. 만약 프로퍼티에 API Key가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다. // 2. 만약 프로퍼티에 API Key가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다.

View File

@@ -34,14 +34,15 @@ public class BusinessToolController {
@PostMapping("/mcp/{name}") @PostMapping("/mcp/{name}")
public ResponseEntity<?> executeDynamicTool( public ResponseEntity<?> executeDynamicTool(
@PathVariable("name") String functionName, @PathVariable("name") String functionName,
@RequestHeader(value = "X-Request-Id", required = false) String headerRequestId, @RequestHeader(value = "x-request-id", required = false) String requestId,
@RequestHeader(value = "trace-id", required = false) String traceId, @RequestHeader(value = "guid", required = false) String guid,
@RequestHeader(value = "request-id", required = false) String requestId, @RequestHeader(value = "mcp-session-id", required = false) String mcpSessionId,
@RequestHeader(value = "employee-id", required = false) String encryptedEmployeeId, @RequestHeader(value = "employee-no", required = false) String employeeNo,
@RequestHeader(value = "virtual-employee-no", required = false) String virtualEmployeeNo,
@RequestBody(required = false) Map<String, Object> arguments) { @RequestBody(required = false) Map<String, Object> arguments) {
ToolExecutionResult result = toolExecutionService.execute( ToolExecutionResult result = toolExecutionService.execute(
functionName, functionName,
new McpRequestHeaders(headerRequestId, traceId, requestId, encryptedEmployeeId), new McpRequestHeaders(requestId, guid, mcpSessionId, employeeNo, virtualEmployeeNo),
arguments); arguments);
ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode()); ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode());
result.headers().forEach(response::header); result.headers().forEach(response::header);

View File

@@ -69,7 +69,7 @@
</aside> </aside>
<section class="stack"> <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>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> </section>
</div> </div>
<p class="footer-note">이 화면은 현재 Tool Pod의 <code>/tool-manifest</code><code>/mcp/{toolName}</code>만 사용합니다. 저장된 케이스는 이 브라우저의 localStorage에만 보관됩니다.</p> <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)}`; 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; } 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) { async function execute(tool = state.selected, body = null) {
if (!tool) throw new Error('실행할 Tool을 선택하세요.'); 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'; $('executeButton').disabled = true; $('httpStatus').textContent = '실행 중'; $('httpStatus').className = 'badge';
try { try {
let response; let response;
@@ -269,13 +269,13 @@
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: tool.name, arguments: payload }, id: Date.now() }; 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', { response = await fetch('/mcp/api/v1/tools/call', {
method: 'POST', 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) body: JSON.stringify(reqPayload)
}); });
} else { } else {
response = await fetch(endpointFor(tool), { response = await fetch(endpointFor(tool), {
method: 'POST', 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) body: JSON.stringify(payload)
}); });
} }
@@ -290,7 +290,7 @@
const elapsed = Math.round(performance.now() - started); const elapsed = Math.round(performance.now() - started);
$('httpStatus').textContent = `HTTP ${displayStatus}`; $('httpStatus').className = `badge ${isOk ? 'ok' : 'fail'}`; $('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; let displayData = data;
if (isGatewayMode && data && typeof data === 'object') { if (isGatewayMode && data && typeof data === 'object') {
if (data.result && data.result.result) { 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 com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties; 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 io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.MockRestServiceServer;
@@ -62,6 +67,68 @@ class AxhubHttpComponentTest {
assertThat(response.status()).isEqualTo("OK"); assertThat(response.status()).isEqualTo("OK");
server.verify(); 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) { 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; 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 com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.McpProperties; 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 io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.util.List; import java.util.List;
import java.util.Map; 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.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator; import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import java.util.Map; import java.util.Map;
import java.util.Arrays;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpToolParam; import org.springaicommunity.mcp.annotation.McpToolParam;
@@ -27,11 +28,35 @@ class McpToolExecutionServiceTest {
void convertsArgumentsToDtoAndExecutesTheMatchedTool() { void convertsArgumentsToDtoAndExecutesTheMatchedTool() {
McpToolExecutionService service = serviceWith(new EchoTool()); McpToolExecutionService service = serviceWith(new EchoTool());
ToolExecutionResult result = service.execute("sample.cmm.value.echo", 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(200, result.statusCode());
assertEquals("hello", ((Map<?, ?>) result.body()).get("value")); assertEquals("hello", ((Map<?, ?>) result.body()).get("value"));
assertEquals("trace-1", result.headers().get("trace-id")); assertEquals("request-1", result.headers().get("x-request-id"));
assertEquals("request-1", result.headers().get("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) { private McpToolExecutionService serviceWith(Object toolBean) {

View File

@@ -5,52 +5,117 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; 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.config.McpProperties;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver; import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import java.lang.reflect.Field; import jakarta.annotation.PreDestroy;
import java.util.List; 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.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
class ToolRegistryHeartbeatSenderTest { class ToolRegistryHeartbeatSenderTest {
@Test @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); 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( ToolRegistryHeartbeatSender sender = new ToolRegistryHeartbeatSender(
applicationContext, new ObjectMapper(), new McpProperties(), mock(ToolSchemaResolver.class)); applicationContext, new ObjectMapper(), new McpProperties(), mock(ToolSchemaResolver.class));
setFieldIfPresent(sender, "gatewayUrl", gatewayUrl);
setField(sender, "podUrl", "http://localhost:8084"); setField(sender, "podUrl", "http://localhost:8084");
return sender;
}
sender.init(); private void invokeLifecycleMethods(Object target, Class<? extends Annotation> annotationType) throws Exception {
for (Method method : target.getClass().getDeclaredMethods()) {
assertThat(sender.getAllScannedTools()).singleElement() if (method.getAnnotation(annotationType) == null) {
.extracting(tool -> tool.getIsRegistered()) continue;
.isEqualTo(false); }
assertThat(registeredTools(sender)).isEmpty(); 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 { 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.setAccessible(true);
field.set(target, value); field.set(target, value);
} }
@SuppressWarnings("unchecked")
private List<?> registeredTools(ToolRegistryHeartbeatSender sender) throws Exception { private void setFieldIfPresent(Object target, String name, Object value) throws Exception {
Field field = ToolRegistryHeartbeatSender.class.getDeclaredField("registeredTools"); try {
field.setAccessible(true); setField(target, name, value);
return (List<?>) field.get(sender); } catch (NoSuchFieldException ignored) {
// The desired implementation has no Gateway registration configuration.
}
} }
static class DisabledTool { static class SampleTool {
@McpTool(name = "test_sample_tool")
@McpTool(name = "test_disabled_tool")
@GrowToolHint
void execute() { 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( new ToolScaffolder.ToolMethodDefinition(
"CustomerGuidance", "searchGuidance", "CTMNILO00007", "Customer guidance", "Search guidance", "cmm", "MCI", "CustomerGuidance", "searchGuidance", "CTMNILO00007", "Customer guidance", "Search guidance", "cmm", "MCI",
false, "NILD", null, false, "NILD", null,
List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", "C001", true)), List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", List.of("C001"), "", true)),
List.of(new ToolScaffolder.FieldDefinition("guidanceStatus", "String", "Guidance status", "OPEN", false)), null), List.of(new ToolScaffolder.FieldDefinition("guidanceStatus", "String", "Guidance status", List.of("OPEN"), "", false)), null),
new ToolScaffolder.ToolMethodDefinition( new ToolScaffolder.ToolMethodDefinition(
"CustomerContract", "searchContract", "CTMCNT00001", "Customer contract", "Search contract", "cmm", "MCI", "CustomerContract", "searchContract", "CTMCNT00001", "Customer contract", "Search contract", "cmm", "MCI",
false, "CNTD", null, false, "CNTD", null,
List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", "C001", true)), List.of(new ToolScaffolder.FieldDefinition("customerId", "String", "Customer ID", List.of("C001"), "", true)),
List.of(new ToolScaffolder.FieldDefinition("contractStatus", "String", "Contract status", "ACTIVE", false)), null))); 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"); 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")); String useCase = Files.readString(sourceRoot.resolve("biz/cmm/usecase/CustomerUseCase.java"));
@@ -59,8 +59,8 @@ class ToolScaffolderTest {
new ToolScaffolder.ToolMethodDefinition( new ToolScaffolder.ToolMethodDefinition(
"EmployeeSearch", "searchEmployee", null, "Employee search", "Search employee", "smp", "HTTP", "EmployeeSearch", "searchEmployee", null, "Employee search", "Search employee", "smp", "HTTP",
false, null, "employee-search", false, null, "employee-search",
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "Employee number", "10001", true)), List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "Employee number", List.of("10001"), "", true)),
List.of(new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong", false)), null))); 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"); Path glowConfig = root.resolve("dap-was-http/src/main/resources/glow/application-glow-local.yml");
assertTrue(Files.exists(glowConfig)); assertTrue(Files.exists(glowConfig));
@@ -71,13 +71,13 @@ class ToolScaffolderTest {
void generatesEnumAndListFieldsInDtoSchemaAndMockResponse() throws Exception { void generatesEnumAndListFieldsInDtoSchemaAndMockResponse() throws Exception {
String moduleName = root.resolve("dap-was-claim").toString(); String moduleName = root.resolve("dap-was-claim").toString();
List<ToolScaffolder.FieldDefinition> inputFields = List.of( 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()), 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.of(), "String", List.of()));
List<ToolScaffolder.FieldDefinition> outputFields = List.of( List<ToolScaffolder.FieldDefinition> outputFields = List.of(
new ToolScaffolder.FieldDefinition("guidanceItems", "List", "Guidance items", "", false, new ToolScaffolder.FieldDefinition("guidanceItems", "List", "Guidance items", List.of(), "", false,
List.of(), "Object", List.of(new ToolScaffolder.FieldDefinition("status", "String", "Status", "OPEN", true)))); 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, ToolScaffolder.scaffold("claim search", "CLM0001", "Claim search", "cmm", "MCI", moduleName,
"tester", "2026.08.12", false, "CLM1", null, null, inputFields, outputFields); "tester", "2026.08.12", false, "CLM1", null, null, inputFields, outputFields);
@@ -107,8 +107,8 @@ class ToolScaffolderTest {
"분석 데이터를 조회하기 위한 도구로 다양한 분석 결과를 제공합니다.", "분석 데이터를 조회하기 위한 도구로 다양한 분석 결과를 제공합니다.",
"cmm", "MCI", moduleName, "테스터", "2026.08.12", "cmm", "MCI", moduleName, "테스터", "2026.08.12",
false, null, null, null, false, null, null, null,
List.of(new ToolScaffolder.FieldDefinition("query", "String", "조회 조건", "계약 분석", true)), List.of(new ToolScaffolder.FieldDefinition("query", "String", "조회 조건", List.of("계약 분석"), "", true)),
List.of(new ToolScaffolder.FieldDefinition("analysisResult", "String", "분석 결과", "정상", false))); 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 sourceRoot = root.resolve("dap-was-korean/src/main/java/io/shinhanlife/dap/mcc");
Path useCase = sourceRoot.resolve("biz/cmm/usecase/AnalysisDataQueryUseCase.java"); Path useCase = sourceRoot.resolve("biz/cmm/usecase/AnalysisDataQueryUseCase.java");
@@ -146,7 +146,7 @@ class ToolScaffolderTest {
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회",
"사번으로 재직 중인 직원을 조회한다.", "smp", "HTTP", moduleName, "사번으로 재직 중인 직원을 조회한다.", "smp", "HTTP", moduleName,
"tester", "2026.08.12", false, null, null, null, "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"); List.of(), "employee");
Path definition = root.resolve("dap-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); 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", "직원 조회", "직원을 조회한다.", ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", "직원을 조회한다.",
"smp", "HTTP", moduleName, "tester", "2026.08.12", false, null, null, null, "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); List.of(), "employee", options);
Path definition = root.resolve("dap-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml"); 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); String request = Files.readString(requestPath);
assertTrue(request.contains("import io.swagger.v3.oas.annotations.media.Schema;")); 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;")); assertTrue(request.contains("private String query;"));
assertFalse(request.contains("McpToolParam")); assertFalse(request.contains("McpToolParam"));
@@ -261,10 +261,10 @@ class ToolScaffolderTest {
void generatesMciToolFromDeclaredInputAndOutputFields() throws Exception { void generatesMciToolFromDeclaredInputAndOutputFields() throws Exception {
String moduleName = root.resolve("dap-was-pay").toString(); String moduleName = root.resolve("dap-was-pay").toString();
List<ToolScaffolder.FieldDefinition> inputFields = List.of( 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),
new ToolScaffolder.FieldDefinition("page", "Integer", "Page number", "1", false)); new ToolScaffolder.FieldDefinition("page", "Integer", "Page number", List.of("1"), "", false));
List<ToolScaffolder.FieldDefinition> outputFields = List.of( 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, ToolScaffolder.scaffold("search hr", "SHEARCH_01", "HR search", "pay", "MCI", moduleName,
"tester", "2026.08.09", true, "DFAG", null, null, inputFields, outputFields); "tester", "2026.08.09", true, "DFAG", null, null, inputFields, outputFields);
@@ -291,7 +291,7 @@ class ToolScaffolderTest {
void generatesMockResponseAndUnitTestSkeletonFromOutputFields() throws Exception { void generatesMockResponseAndUnitTestSkeletonFromOutputFields() throws Exception {
String moduleName = root.resolve("dap-was-cus").toString(); String moduleName = root.resolve("dap-was-cus").toString();
List<ToolScaffolder.FieldDefinition> outputFields = List.of( 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, ToolScaffolder.scaffold("claim search", "CLM0001", "Claim search", "cmm", "MCI", moduleName,
"tester", "2026.08.10", true, null, null, null, List.of(), outputFields); "tester", "2026.08.10", true, null, null, null, List.of(), outputFields);
@@ -318,12 +318,12 @@ class ToolScaffolderTest {
void generatesDtoPackageAndRemovesDuplicateResponseFields() throws Exception { void generatesDtoPackageAndRemovesDuplicateResponseFields() throws Exception {
String moduleName = root.resolve("dap-was-http").toString(); String moduleName = root.resolve("dap-was-http").toString();
List<ToolScaffolder.FieldDefinition> outputFields = List.of( List<ToolScaffolder.FieldDefinition> outputFields = List.of(
new ToolScaffolder.FieldDefinition("resultCode", "String", "API result", "SUCCESS", true), new ToolScaffolder.FieldDefinition("resultCode", "String", "API result", List.of("SUCCESS"), "", true),
new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", "Hong Gildong", false), new ToolScaffolder.FieldDefinition("employeeName", "String", "Employee name", List.of("Hong Gildong"), "", false),
new ToolScaffolder.FieldDefinition("employeeName", "String", "Duplicate name", "Duplicate", false)); new ToolScaffolder.FieldDefinition("employeeName", "String", "Duplicate name", List.of("Duplicate"), "", false));
List<ToolScaffolder.FieldDefinition> inputFields = List.of( 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, String resultLog = ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "Employee search", "smp", "HTTP", moduleName,
"tester", "2026.08.11", false, null, null, null, inputFields, outputFields); "tester", "2026.08.11", false, null, null, null, inputFields, outputFields);
@@ -434,10 +434,10 @@ class ToolScaffolderTest {
void generatesObjectListAsNestedInnerClassWithoutSeparateItemSource() throws Exception { void generatesObjectListAsNestedInnerClassWithoutSeparateItemSource() throws Exception {
String moduleName = root.resolve("dap-was-inner-list").toString(); String moduleName = root.resolve("dap-was-inner-list").toString();
List<ToolScaffolder.FieldDefinition> fields = List.of( 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( List.of(), "Object", List.of(
new ToolScaffolder.FieldDefinition("date", "String", "date", "2026-08-13", true), new ToolScaffolder.FieldDefinition("date", "String", "date", List.of("2026-08-13"), "", true),
new ToolScaffolder.FieldDefinition("users", "Integer", "users", "10", false)))); new ToolScaffolder.FieldDefinition("users", "Integer", "users", List.of("10"), "", false))));
ToolScaffolder.scaffold("ga activity status", "GA001", "GA status", "GA status", "ana", "HTTP", ToolScaffolder.scaffold("ga activity status", "GA001", "GA status", "GA status", "ana", "HTTP",
moduleName, "tester", "2026.08.13", false, null, null, null, List.of(), fields); moduleName, "tester", "2026.08.13", false, null, null, null, List.of(), fields);

View File

@@ -1,17 +1,13 @@
package io.shinhanlife.dap.mcc.mcp; 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.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.shinhanlife.dap.lib.mcp.McpRequestHeaderContext;
import io.modelcontextprotocol.server.McpSyncServer; import io.shinhanlife.dap.lib.mcp.McpRequestHeaderFilter;
import io.shinhanlife.dap.lib.mcp.*; import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
import java.lang.reflect.Method; import java.lang.reflect.RecordComponent;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
@@ -20,41 +16,34 @@ import org.springframework.mock.web.MockHttpServletResponse;
class McpRequestHeaderFilterTest { class McpRequestHeaderFilterTest {
@Test @Test
void capturesOptionalMcpHeadersOnlyForTheCurrentRequest() throws Exception { void capturesDapmsHeadersOnlyForTheCurrentRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp"); MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
request.addHeader("X-Request-Id", "gateway-request-id"); request.addHeader("x-request-id", "request-001");
request.addHeader("trace-id", "trace-001"); request.addHeader("guid", "guid-001");
request.addHeader("request-id", "tool-request-001"); request.addHeader("mcp-session-id", "session-001");
request.addHeader("employee-id", "encrypted-employee-id"); request.addHeader("employee-no", "ENC(employee)");
request.addHeader("virtual-employee-no", "ENC(virtual)");
new McpRequestHeaderFilter().doFilter(request, new MockHttpServletResponse(), (req, res) -> { new McpRequestHeaderFilter().doFilter(request, new MockHttpServletResponse(), (req, res) ->
McpRequestHeaders headers = McpRequestHeaderContext.current(); assertThat(asMap(McpRequestHeaderContext.current())).containsExactly(
assertEquals("gateway-request-id", headers.headerRequestId()); Map.entry("requestId", "request-001"),
assertEquals("trace-001", headers.traceId()); Map.entry("guid", "guid-001"),
assertEquals("tool-request-001", headers.requestId()); Map.entry("mcpSessionId", "session-001"),
assertEquals("encrypted-employee-id", headers.encryptedEmployeeId()); Map.entry("employeeNo", "ENC(employee)"),
}); Map.entry("virtualEmployeeNo", "ENC(virtual)")));
assertNull(McpRequestHeaderContext.current()); assertNull(McpRequestHeaderContext.current());
} }
@Test private Map<String, Object> asMap(McpRequestHeaders headers) {
void forwardsCapturedHeadersToSharedToolExecutionService() throws Exception { try {
McpToolExecutionService service = mock(McpToolExecutionService.class); Map<String, Object> values = new LinkedHashMap<>();
McpRequestHeaders headers = new McpRequestHeaders( for (RecordComponent component : McpRequestHeaders.class.getRecordComponents()) {
"gateway-request-id", "trace-001", "tool-request-001", "encrypted-employee-id"); values.put(component.getName(), component.getAccessor().invoke(headers));
doReturn(new ToolExecutionResult(200, Map.of("result", "ok"), Map.of())) }
.when(service).execute(eq("sampleTool"), eq(headers), eq(Map.of("key", "value"))); return values;
ToolPodMcpToolSynchronizer synchronizer = new ToolPodMcpToolSynchronizer( } catch (ReflectiveOperationException exception) {
mock(McpSyncServer.class), mock(ToolRegistryHeartbeatSender.class), service, new ObjectMapper()); throw new AssertionError(exception);
}
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"));
} }
} }

View File

@@ -1,32 +1,30 @@
package io.shinhanlife.dap.mcc.presentation; package io.shinhanlife.dap.mcc.presentation;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.Parameter; import java.util.Arrays;
import java.util.Map; import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
class BusinessToolControllerHeaderContractTest { class BusinessToolControllerHeaderContractTest {
@Test @Test
void encryptedEmployeeIdHeaderIsOptional() throws Exception { void receivesTheHeadersForwardedByDapms() {
Method method = BusinessToolController.class.getDeclaredMethod( Method method = Arrays.stream(BusinessToolController.class.getDeclaredMethods())
"executeDynamicTool", .filter(candidate -> candidate.getName().equals("executeDynamicTool"))
String.class, .findFirst()
String.class, .orElseThrow();
String.class,
String.class,
String.class,
Map.class);
Parameter employeeIdParameter = method.getParameters()[4]; List<String> headerNames = Arrays.stream(method.getParameters())
RequestHeader requestHeader = employeeIdParameter.getAnnotation(RequestHeader.class); .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()); assertThat(headerNames).containsExactly(
assertFalse(requestHeader.required()); "x-request-id", "guid", "mcp-session-id", "employee-no", "virtual-employee-no");
} }
} }