fix: align tool headers
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 3m51s

This commit is contained in:
jade
2026-08-18 09:52:54 +09:00
parent 8bd9b9e922
commit 406e3c79b7
14 changed files with 210 additions and 240 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,14 +21,15 @@ 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 {
McpRequestHeaderContext.clear(); McpRequestHeaderContext.clear();
} }
} }
} }

View File

@@ -2,8 +2,9 @@ package io.shinhanlife.dap.lib.mcp;
/** Optional request headers propagated from an MCP HTTP request to a Tool invocation. */ /** 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);
} }
} }
@@ -98,4 +99,4 @@ public class McpToolExecutionService {
if (requestId != null) body.put("request_id", requestId); if (requestId != null) body.put("request_id", requestId);
return new ToolExecutionResult(statusCode, body, Map.of()); return new ToolExecutionResult(statusCode, body, Map.of());
} }
} }

View File

@@ -19,7 +19,7 @@ import io.shinhanlife.dap.lib.mcp.security.ApiKeyInterceptor;
* 수정일 수정자 수정내용 * 수정일 수정자 수정내용
* ---------- -------- --------------------------- * ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성 * 2026.09.01 0986406 최초생성
* *
* </pre> * </pre>
*/ */
@Configuration @Configuration
@@ -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 경로는 인증 제외
@@ -51,4 +51,4 @@ public class WebConfig implements WebMvcConfigurer {
.exposedHeaders("Mcp-Session-Id") .exposedHeaders("Mcp-Session-Id")
.allowCredentials(true); .allowCredentials(true);
} }
} }

View File

@@ -21,7 +21,7 @@ import java.util.Map;
* 수정일 수정자 수정내용 * 수정일 수정자 수정내용
* ---------- -------- --------------------------- * ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성 * 2026.09.01 0986406 최초생성
* *
* </pre> * </pre>
*/ */
@Slf4j @Slf4j
@@ -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가 하나도 설정되어 있지 않다면 (개발/로컬 환경 등) 인증 없이 통과시킵니다.
@@ -63,10 +63,10 @@ public class ApiKeyInterceptor implements HandlerInterceptor {
// 4. 추출한 Tenant ID를 현재 스레드의 로깅 컨텍스트(MDC)에 저장합니다. // 4. 추출한 Tenant ID를 현재 스레드의 로깅 컨텍스트(MDC)에 저장합니다.
// 이렇게 하면 이 요청이 끝날 때까지 찍히는 모든 로그에 어떤 테넌트가 호출했는지 자동으로 기록됩니다. // 이렇게 하면 이 요청이 끝날 때까지 찍히는 모든 로그에 어떤 테넌트가 호출했는지 자동으로 기록됩니다.
MDC.put("tenantId", tenantId); MDC.put("tenantId", tenantId);
// 5. 필요시 컨트롤러 로직에서 사용할 수 있도록 Request 속성에도 담아줍니다. // 5. 필요시 컨트롤러 로직에서 사용할 수 있도록 Request 속성에도 담아줍니다.
request.setAttribute("tenantId", tenantId); request.setAttribute("tenantId", tenantId);
log.debug(" [보안 통과] API Key 인증 성공 - 접속 테넌트: {}", tenantId); log.debug(" [보안 통과] API Key 인증 성공 - 접속 테넌트: {}", tenantId);
return true; // 인증 통과! 컨트롤러로 진행 return true; // 인증 통과! 컨트롤러로 진행
@@ -77,4 +77,4 @@ public class ApiKeyInterceptor implements HandlerInterceptor {
// 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다. // 6. 메모리 누수를 방지하기 위해 요청 처리가 완전히 끝나면 MDC에서 테넌트 정보를 지워줍니다.
MDC.remove("tenantId"); MDC.remove("tenantId");
} }
} }

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

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

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

@@ -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");
@@ -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,115 +0,0 @@
package io.shinhanlife.dap.lib.manifest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.mcc.dto.ToolMetadata;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ToolManifestServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void buildsStandardManifestAndDerivesRevisionFromToolDefinition() throws Exception {
McpProperties properties = manifestProperties("insurance-processing", "processing.");
ToolManifestService service = new ToolManifestService(
() -> List.of(tool("processing.contract.inquiry", "1.2.0", 3000)), objectMapper, properties);
ToolManifestResponse manifest = service.currentManifest();
assertEquals("insurance-processing", manifest.bundleId());
assertTrue(manifest.revision().matches("\\d+"));
assertEquals(1, manifest.tools().size());
ToolManifestItem item = manifest.tools().getFirst();
assertEquals("processing.contract.inquiry", item.name());
assertEquals("http://tool-processing.ax-hub.svc.cluster.local:8080/mcp/processing.contract.inquiry",
item.endpoint());
assertEquals("계약 조회", item.title());
assertEquals("object", item.inputSchema().get("type"));
assertTrue(item.annotations().readOnlyHint());
assertEquals("1.2.0", item.meta().version());
assertEquals(3000, item.meta().timeoutMillis());
assertEquals("when to use", item.meta().whenToUse());
assertEquals("when not to use", item.meta().whenNotToUse());
assertEquals("io limits", item.meta().ioLimits());
String payload = objectMapper.writeValueAsString(manifest);
assertTrue(payload.contains("\"when_to_use\":\"when to use\""));
assertTrue(payload.contains("\"when_not_to_use\":\"when not to use\""));
assertTrue(payload.contains("\"io_limits\":\"io limits\""));
assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"),
item.meta().exampleQueries());
}
@Test
void changesRevisionWhenToolDefinitionChanges() {
McpProperties properties = manifestProperties("insurance-processing", "processing.");
ToolManifestService before = new ToolManifestService(
() -> List.of(tool("processing.contract.inquiry", "1.2.0", 3000)), objectMapper, properties);
ToolManifestService after = new ToolManifestService(
() -> List.of(tool("processing.contract.inquiry", "1.2.0", 5000)), objectMapper, properties);
assertTrue(!before.currentManifest().revision().equals(after.currentManifest().revision()));
}
@Test
void rejectsEntireManifestWhenToolNameDoesNotMatchConfiguredPrefix() {
McpProperties properties = manifestProperties("insurance-processing", "processing.");
ToolManifestService service = new ToolManifestService(
() -> List.of(tool("notification_sms_send", "1.0.0", 3000)), objectMapper, properties);
IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest);
assertTrue(error.getMessage().contains("name-prefix"));
}
@Test
void rejectsEntireManifestWhenToolNamesAreDuplicated() {
McpProperties properties = manifestProperties("insurance-processing", "processing.");
ToolManifestService service = new ToolManifestService(
() -> List.of(tool("processing.contract.inquiry", "1.0.0", 3000),
tool("processing.contract.inquiry", "1.0.1", 3000)), objectMapper, properties);
IllegalStateException error = assertThrows(IllegalStateException.class, service::currentManifest);
assertTrue(error.getMessage().contains("duplicate"));
}
private McpProperties manifestProperties(String bundleId, String namePrefix) {
McpProperties properties = new McpProperties();
McpProperties.Manifest manifest = new McpProperties.Manifest();
manifest.setBundleId(bundleId);
manifest.setNamePrefix(namePrefix);
properties.setManifest(manifest);
return properties;
}
private ToolMetadata tool(String name, String version, long timeoutMillis) {
return ToolMetadata.builder()
.name(name)
.podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080")
.displayName("계약 조회")
.description("계약번호로 계약 정보를 조회합니다.")
.exampleQueries(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"))
.tags(List.of("계약"))
.ownerOrg("MCP_TOOL")
.whenToUse("when to use")
.whenNotToUse("when not to use")
.ioLimits("io limits")
.parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")),
"required", List.of("contractNo"), "additionalProperties", false))
.semver(version)
.timeoutMillis(timeoutMillis)
.enabled(true)
.readOnlyHint(true)
.destructiveHint(false)
.idempotentHint(true)
.openWorldHint(false)
.build();
}
}

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

View File

@@ -115,7 +115,7 @@ services:
context: . context: .
dockerfile: dap-was-pro/Dockerfile dockerfile: dap-was-pro/Dockerfile
ports: ports:
- "8085:8085" - "8285:8085"
environment: environment:
- TZ=Asia/Seoul - TZ=Asia/Seoul
- AXHUB_GATEWAY_URL=http://gateway:8081 - AXHUB_GATEWAY_URL=http://gateway:8081
@@ -132,7 +132,7 @@ services:
context: . context: .
dockerfile: dap-was-sys/Dockerfile dockerfile: dap-was-sys/Dockerfile
ports: ports:
- "8086:8086" - "8286:8086"
environment: environment:
- TZ=Asia/Seoul - TZ=Asia/Seoul
- AXHUB_GATEWAY_URL=http://gateway:8081 - AXHUB_GATEWAY_URL=http://gateway:8081