forked from kimhyungsik/ax_hub_mcp_tool
fix: align tool headers and remove gateway registration
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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를 입력해주세요. ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user