feat: update MCP contracts and integrations
All checks were successful
Deploy Tools / deploy (push) Successful in 1m34s

This commit is contained in:
jade
2026-08-25 16:37:08 +09:00
parent 53bc1487c8
commit d3cc1f7f33
47 changed files with 1377 additions and 252 deletions

View File

@@ -98,10 +98,18 @@ DATMS
```powershell
$headers = @{
'X-Tool-Server-API-Key' = $env:TOOL_SERVER_API_KEY
'guid' = 'guid-local-001'
'x-request-id' = 'request-local-001'
'employee-no' = '100001'
'virtual-employee-no' = 'V100001'
'X-Guid' = 'guid-local-001'
'X-Praf-No' = '100001'
'X-Request-Id' = 'request-local-001'
'X-Request-Time' = '2026-08-25T12:34:56+09:00'
'X-Vrtl-Praf-No' = 'V100001'
'X-App-Code' = 'DATMT'
'X-Project-Code' = 'AXHUB'
'X-User-Ip' = '10.0.0.10'
'X-Caller-Ip' = '10.0.0.20'
'X-Caller-Host' = 'caller.example.internal'
'X-Channel' = 'MCP'
'X-Agent-Id' = 'agent-local-001'
'mcp-session-id' = 'session-local-001'
}
@@ -120,14 +128,24 @@ DATMS가 DATMT Tool Service를 호출할 때 사용하는 헤더는 다음과
| 헤더 | 필수 여부 | 용도 | 전달 동작 |
|---|---|---|---|
| `X-Tool-Server-API-Key` | 인증 설정 시 필수 | DATMS와 DATMT 사이의 Tool Server 인증 | `mcp.security.api-key` 또는 `api-keys`와 비교 |
| `guid` | 선택 | 업무 호출 상관관계 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 |
| `x-request-id` | 선택 | 요청 추적 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 |
| `employee-no` | 선택 | 실제 사용자 사번 | 하위 HTTP 호출로 전달 |
| `virtual-employee-no` | 선택 | 가상 사용자 사번 | 하위 HTTP 호출로 전달 |
| `X-Guid` | 선택 | 업무 호출 상관관계 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 |
| `X-Praf-No` | 선택 | 실제 사용자 사번 | 세션 조회와 하위 HTTP 호출로 전달 |
| `X-Request-Id` | 선택 | 요청 추적 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 |
| `X-Request-Time` | 선택 | 요청 발생 시각 | 하위 HTTP 호출로 전달 |
| `X-Vrtl-Praf-No` | 선택 | 가상 사용자 사번 | 하위 HTTP 호출로 전달 |
| `X-App-Code` | 선택 | 호출 애플리케이션 코드 | 하위 HTTP 호출로 전달 |
| `X-Project-Code` | 선택 | 호출 프로젝트 코드 | 하위 HTTP 호출로 전달 |
| `X-User-Ip` | 선택 | 사용자 IP 주소 | 하위 HTTP 호출로 전달 |
| `X-Caller-Ip` | 선택 | 호출 시스템 IP 주소 | 하위 HTTP 호출로 전달 |
| `X-Caller-Host` | 선택 | 호출 시스템 호스트명 | 하위 HTTP 호출로 전달 |
| `X-Channel` | 선택 | 호출 채널 | 하위 HTTP 호출로 전달 |
| `X-Agent-Id` | 선택 | 호출 Agent 식별자 | 하위 HTTP 호출로 전달 |
| `mcp-session-id` | 선택 | MCP 세션 식별자 | 성공 응답과 하위 HTTP 호출로 전달 |
REST 경로 `/mcp/{toolName}`은 Controller가 위 헤더를 직접 읽습니다. MCP Streamable HTTP 경로 `/mcp``McpRequestHeaderFilter`가 동일한 헤더를 `McpRequestHeaderContext`에 저장한 뒤 Tool 실행과 하위 HTTP 호출에 전달합니다. 헤더가 없는 하위 HTTP 호출에는 `X-ANONYMOUS-REQ: AXHUB-TOOL`이 설정됩니다.
기존 `guid`, `employee-no`, `virtual-employee-no` 헤더는 지원하지 않습니다.
주요 실행 응답은 다음과 같습니다.
| HTTP 상태 | 코드 | 의미 |

View File

@@ -1,7 +1,10 @@
package io.shinhanlife.dat.lib.adapter.test;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -39,10 +42,13 @@ public class MockEimsHttpServer {
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
try {
try (InputStream inputStream = resource.getInputStream()) {
log.info("[MockEimsHttpServer] HTTP mock request. toolName={}, body={}", toolName, request);
return ResponseEntity.ok(objectMapper.readTree(resource.getInputStream()));
} catch (Exception e) {
return ResponseEntity.ok(objectMapper.readTree(inputStream));
} catch (JsonProcessingException e) {
log.warn("[MockEimsHttpServer] Invalid mock response JSON. toolName={}", toolName, e);
return ResponseEntity.internalServerError().build();
} catch (IOException e) {
log.warn("[MockEimsHttpServer] Unable to read mock response. toolName={}", toolName, e);
return ResponseEntity.internalServerError().build();
}

View File

@@ -67,7 +67,7 @@ public class ToolSlaMonitoringAspect {
return result;
} catch (Throwable e) {
} catch (RuntimeException e) {
if (stopWatch.isRunning()) {
stopWatch.stop();
}

View File

@@ -92,11 +92,19 @@ public class AxhubHttpComponent {
if (inbound == null) {
header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST);
} else {
putIfPresent(header, "x-request-id", inbound.requestId());
putIfPresent(header, "guid", inbound.guid());
putIfPresent(header, "X-Guid", inbound.guid());
putIfPresent(header, "X-Praf-No", inbound.prafNo());
putIfPresent(header, "X-Request-Id", inbound.requestId());
putIfPresent(header, "X-Request-Time", inbound.requestTime());
putIfPresent(header, "X-Vrtl-Praf-No", inbound.vrtlPrafNo());
putIfPresent(header, "X-App-Code", inbound.appCode());
putIfPresent(header, "X-Project-Code", inbound.projectCode());
putIfPresent(header, "X-User-Ip", inbound.userIp());
putIfPresent(header, "X-Caller-Ip", inbound.callerIp());
putIfPresent(header, "X-Caller-Host", inbound.callerHost());
putIfPresent(header, "X-Channel", inbound.channel());
putIfPresent(header, "X-Agent-Id", inbound.agentId());
putIfPresent(header, "mcp-session-id", inbound.mcpSessionId());
putIfPresent(header, "employee-no", inbound.employeeNo());
putIfPresent(header, "virtual-employee-no", inbound.virtualEmployeeNo());
}
header.setReadTimeout(timeout == 0 ? defaultReadTimeout() : timeout);
return header;

View File

@@ -3,7 +3,6 @@ package io.shinhanlife.dat.lib.integration.mci.component;
import io.shinhanlife.dat.lib.session.dto.SessionDto;
import io.shinhanlife.dat.lib.util.SessionUtil;
import io.shinhanlife.dat.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.BizException;
import io.shinhanlife.glow.communication.dto.CommonHeader;
import io.shinhanlife.glow.communication.dto.HeaderDefaults;
import io.shinhanlife.glow.communication.dto.Transfer;
@@ -91,12 +90,7 @@ public class AxhubMciComponent {
CommonHeader reqHeader = (CommonHeader) request.getHeader();
log.info("[AxhubMciComponent] {} MCI 호출시작 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
Transfer<O> response;
try {
response = (Transfer<O>) mci.sync(request);
} catch (RuntimeException e) {
throw new BizException("CST00477", new String[]{"대내 MCI 호출 결과 처리중 오류가 발생했습니다."}, e);
}
Transfer<O> response = (Transfer<O>) mci.sync(request);
log.info("[AxhubMciComponent] {} MCI 호출종료 (수신서비스: {})", reqHeader.getItrfId(), reqHeader.getRcvSvcId());
@@ -109,7 +103,7 @@ public class AxhubMciComponent {
return response;
}
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto) throws Exception {
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto) {
Map<HeaderDefaults, String> commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId);
CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap);
@@ -132,7 +126,7 @@ public class AxhubMciComponent {
* @param <I> inputDto 제너릭
*/
@SuppressWarnings("unchecked")
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto, Class<O> resBodyClass) throws Exception {
public <O, I> Transfer<O> callTo(String itrfName, String rcvSvcId, I inputDto, Class<O> resBodyClass) {
Map<HeaderDefaults, String> commonHeaderMap = createCommonHeaderMap(itrfName, rcvSvcId);
CommonHeader header = CommonHeaderFactory.createRequestHeader(commonHeaderMap);
@@ -153,9 +147,8 @@ public class AxhubMciComponent {
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
* @throws Exception Exception
*/
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO, Class<O> resBodyClass) throws Exception {
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO, Class<O> resBodyClass) {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return callTo(itrfName, rcvSvcId, inputDTO, resBodyClass);
@@ -168,9 +161,8 @@ public class AxhubMciComponent {
* @return Transfer
* @param <O> resBodyClass 제너릭
* @param <I> inputDto 제너릭
* @throws Exception Exception
*/
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO) throws Exception {
public <O, I> Transfer<O> callTo(String itrfName, I inputDTO) {
String className = inputDTO.getClass().getSimpleName();
String rcvSvcId = className.replace("_I", "");
return callTo(itrfName, rcvSvcId, inputDTO);

View File

@@ -10,7 +10,7 @@ import io.shinhanlife.dat.lib.session.mci.AxhubSessionService;
import io.shinhanlife.dat.lib.session.dto.SessionDto;
import org.springframework.beans.factory.annotation.Autowired;
/** Captures optional correlation and employee headers for an MCP HTTP call and creates mock session. */
/** Captures optional Tool request headers for an MCP HTTP call and creates the user session. */
@Component
public class McpRequestHeaderFilter implements Filter {
@@ -29,18 +29,26 @@ public class McpRequestHeaderFilter implements Filter {
return;
}
String empNo = request.getHeader("employee-no");
String prafNo = request.getHeader("X-Praf-No");
McpRequestHeaderContext.set(new McpRequestHeaders(
request.getHeader("x-request-id"),
request.getHeader("guid"),
request.getHeader("mcp-session-id"),
empNo,
request.getHeader("virtual-employee-no")));
request.getHeader("X-Guid"),
prafNo,
request.getHeader("X-Request-Id"),
request.getHeader("X-Request-Time"),
request.getHeader("X-Vrtl-Praf-No"),
request.getHeader("X-App-Code"),
request.getHeader("X-Project-Code"),
request.getHeader("X-User-Ip"),
request.getHeader("X-Caller-Ip"),
request.getHeader("X-Caller-Host"),
request.getHeader("X-Channel"),
request.getHeader("X-Agent-Id"),
request.getHeader("mcp-session-id")));
// Fetch session info via MCI using employee-no (Only if sessionService is available, e.g. in Tool Pods)
if (sessionService != null && empNo != null && !empNo.isEmpty()) {
SessionDto sessionDto = sessionService.fetchUserSession(empNo);
// Fetch session info via MCI using X-Praf-No (only when the service is available in a Tool Pod).
if (sessionService != null && prafNo != null && !prafNo.isEmpty()) {
SessionDto sessionDto = sessionService.fetchUserSession(prafNo);
if (sessionDto != null) {
// Set to request attribute so SessionUtil can find it
request.setAttribute("userInfo", sessionDto);

View File

@@ -2,9 +2,17 @@ package io.shinhanlife.dat.lib.mcp;
/** Optional request headers propagated from an MCP HTTP request to a Tool invocation. */
public record McpRequestHeaders(
String requestId,
String guid,
String mcpSessionId,
String employeeNo,
String virtualEmployeeNo) {
String prafNo,
String requestId,
String requestTime,
String vrtlPrafNo,
String appCode,
String projectCode,
String userIp,
String callerIp,
String callerHost,
String channel,
String agentId,
String mcpSessionId) {
}

View File

@@ -3,7 +3,10 @@ package io.shinhanlife.dat.lib.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
import io.shinhanlife.dat.lib.util.ToolSchemaResolver;
import io.shinhanlife.dat.lib.util.ToolSchemaResolver.ToolSchemaResourceException;
import io.shinhanlife.dat.lib.validation.ToolArgumentSchemaValidator;
import io.shinhanlife.glow.BizException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
@@ -27,7 +30,7 @@ public class McpToolExecutionService {
String requestId = requestHeaders == null ? null : requestHeaders.requestId();
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);
log.info("[Tool] IN - X-Guid: {}, X-Request-Id: {}, tool: {}", guid, requestId, functionName);
McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName);
if (resolvedTool == null) {
@@ -44,12 +47,29 @@ public class McpToolExecutionService {
return outputFailure;
}
Map<String, String> headers = new HashMap<>();
if (requestId != null) headers.put("x-request-id", requestId);
if (guid != null) headers.put("guid", guid);
if (requestId != null) headers.put("X-Request-Id", requestId);
if (guid != null) headers.put("X-Guid", guid);
if (mcpSessionId != null) headers.put("mcp-session-id", mcpSessionId);
log.info("[Tool] OUT - guid: {}, x-request-id: {}, tool: {}", guid, requestId, functionName);
log.info("[Tool] OUT - X-Guid: {}, X-Request-Id: {}, tool: {}", guid, requestId, functionName);
return new ToolExecutionResult(200, methodResult, headers);
} catch (Exception error) {
} catch (IllegalArgumentException error) {
log.error("[Tool] Tool argument conversion failed. tool={}", functionName, error);
return error(502, "TOOL_ERROR", "Tool execution failed", requestId);
} catch (IllegalAccessException error) {
log.error("[Tool] Tool method is not accessible. tool={}", functionName, error);
return error(502, "TOOL_ERROR", "Tool execution failed", requestId);
} catch (InvocationTargetException error) {
Throwable cause = error.getCause();
if (cause instanceof BizException businessError) {
log.error("[Tool] Tool business execution failed. tool={}", functionName, businessError);
return error(502, "TOOL_ERROR", "Tool execution failed", requestId);
}
if (cause instanceof RuntimeException runtimeError) {
throw runtimeError;
}
if (cause instanceof Error jvmError) {
throw jvmError;
}
log.error("[Tool] Tool execution failed. tool={}", functionName, error);
return error(502, "TOOL_ERROR", "Tool execution failed", requestId);
}
@@ -62,8 +82,8 @@ public class McpToolExecutionService {
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
ValidationResponse result = toolArgumentSchemaValidator.validate(schema, arguments);
return result.valid() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId);
} catch (Exception error) {
log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
} catch (ToolSchemaResourceException error) {
log.error("[Tool] Input schema resource loading failed. tool={}", tool.annotation().name(), error);
return null;
}
}
@@ -73,7 +93,8 @@ public class McpToolExecutionService {
return objectMapper.convertValue(arguments, method.getParameterTypes()[0]);
}
private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument) throws Exception {
private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument)
throws IllegalAccessException, InvocationTargetException {
return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument);
}
@@ -84,8 +105,8 @@ public class McpToolExecutionService {
if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).valid()) {
return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId);
}
} catch (Exception error) {
log.error("[Tool] Output schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
} catch (ToolSchemaResourceException error) {
log.error("[Tool] Output schema resource loading failed. tool={}", tool.annotation().name(), error);
}
return null;
}

View File

@@ -1,7 +1,7 @@
package io.shinhanlife.dat.lib.session.mci;
import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.dat.lib.session.dto.SessionDto;
import io.shinhanlife.glow.BizException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -15,7 +15,7 @@ import java.util.List;
@RequiredArgsConstructor
public class AxhubSessionService {
private final AxhubMciComponent mciComponent;
private final MciOnbszClient mciOnbszClient;
public SessionDto fetchUserSession(String employeeNo) {
if (employeeNo == null || employeeNo.trim().isEmpty()) {
@@ -23,18 +23,18 @@ public class AxhubSessionService {
}
try {
ONBSA0790_I request = ONBSA0790_I.builder()
.plnrPrafScrnInqrInDto(ONBSA0790_I.PlnrPrafScrnInqrInDto.builder()
ONBSZ0460_I.CmmnPrafIfinInDto input = ONBSZ0460_I.CmmnPrafIfinInDto.builder()
.prafNo(employeeNo)
.build())
.build();
ONBSZ0460_I request = ONBSZ0460_I.builder()
.cmmnPrafIfinInDto(List.of(input))
.build();
// callTo makes a synchronous MCI call.
var responseTransfer = mciComponent.callTo("ONBSA0790", "ONBSA", request, ONBSA0790_O.class);
ONBSA0790_O response = responseTransfer.getBody();
ONBSZ0460_O response = mciOnbszClient.callOnbsz0460(request);
if (response != null && response.getPrafInfoDto() != null && !response.getPrafInfoDto().isEmpty()) {
ONBSA0790_O.PrafInfoDto info = response.getPrafInfoDto().get(0);
if (response != null && response.getCmmnPrafIfinOutDto() != null
&& !response.getCmmnPrafIfinOutDto().isEmpty()) {
ONBSZ0460_O.CmmnPrafIfinOutDto info = response.getCmmnPrafIfinOutDto().get(0);
SessionDto sessionDto = new SessionDto();
sessionDto.setOgnzNo(info.getOgnzAsrtCd());
@@ -48,11 +48,11 @@ public class AxhubSessionService {
sessionDto.setOgnzAsrtCd(info.getOgnzAsrtCd());
sessionDto.setOgnzLeveCd(info.getOgnzLeveCd());
log.info("[AxhubSessionService] MCI ONBSA0790 조회 성공: 사번={}, 조직코드={}", employeeNo, info.getOgnzAsrtCd());
log.info("[AxhubSessionService] MCI ONBSZ0460 조회 성공: 사번={}, 조직코드={}", employeeNo, info.getOgnzAsrtCd());
return sessionDto;
}
} catch (Exception e) {
log.error("[AxhubSessionService] MCI ONBSA0790 사원 정보 조회 실패 (사번: {})", employeeNo, e);
} catch (BizException e) {
log.error("[AxhubSessionService] MCI ONBSZ0460 사원 정보 조회 실패 (사번: {})", employeeNo, e);
}
return null;
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dat.lib.session.mci;
import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.glow.communication.dto.Transfer;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class MciOnbszClient {
private static final String INTERFACE_ID = "ONBSZ0460";
private static final String RECEIVE_SERVICE_ID = "ONBSZ";
private final AxhubMciComponent mciComponent;
public ONBSZ0460_O callOnbsz0460(ONBSZ0460_I request) {
Transfer<ONBSZ0460_O> transfer = mciComponent.callTo(
INTERFACE_ID, RECEIVE_SERVICE_ID, request, ONBSZ0460_O.class);
return transfer == null ? null : transfer.getBody();
}
}

View File

@@ -1,25 +0,0 @@
package io.shinhanlife.dat.lib.session.mci;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import lombok.*;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ONBSA0790_I {
@GlowTrgmField(order = 1, length = 100, description = "설계사인사화면조회입력DTO")
private PlnrPrafScrnInqrInDto plnrPrafScrnInqrInDto;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PlnrPrafScrnInqrInDto {
@GlowTrgmField(order = 1, length = 7, description = "인사번호")
private String prafNo;
}
}

View File

@@ -1,53 +0,0 @@
package io.shinhanlife.dat.lib.session.mci;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import lombok.*;
import java.util.List;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ONBSA0790_O {
@GlowTrgmField(order = 1, length = 1000, description = "인사정보DTO리스트")
private List<PrafInfoDto> prafInfoDto;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PrafInfoDto {
@GlowTrgmField(order = 1, length = 7, description = "인사번호")
private String prafNo;
@GlowTrgmField(order = 2, length = 2, description = "인사유형코드")
private String prafTypeCd;
@GlowTrgmField(order = 3, length = 100, description = "인사명")
private String prafNm;
@GlowTrgmField(order = 22, length = 3, description = "영업직책코드")
private String bsduCd;
@GlowTrgmField(order = 28, length = 6, description = "조직분류코드")
private String ognzAsrtCd;
@GlowTrgmField(order = 29, length = 6, description = "인사조직분류코드")
private String psmrAsrtCd;
@GlowTrgmField(order = 30, length = 6, description = "영업규정분류코드")
private String sbsnRulpAsrtCd;
@GlowTrgmField(order = 31, length = 6, description = "조직레벨코드")
private String ognzLeveCd;
@GlowTrgmField(order = 32, length = 7, description = "지점번호")
private String brafNo;
@GlowTrgmField(order = 33, length = 7, description = "영업소코드")
private String bsquCd;
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.dat.lib.session.mci;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ONBSZ0460_I {
@GlowTrgmField(order = 1, description = "공통인사정보조회InDto", type = "gm")
private List<CmmnPrafIfinInDto> cmmnPrafIfinInDto;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class CmmnPrafIfinInDto {
@GlowTrgmField(order = 1, length = 6, description = "마감년월")
private String closYm;
@GlowTrgmField(order = 2, length = 7, description = "조직번호")
private String ognzNo;
@GlowTrgmField(order = 3, length = 8, description = "인사번호")
private String prafNo;
@GlowTrgmField(order = 4, length = 3, description = "인사조직분류코드")
private String psmrAsrtCd;
@GlowTrgmField(order = 5, length = 1, description = "트레이너구분코드")
private String trnrSccd;
}
}

View File

@@ -0,0 +1,187 @@
package io.shinhanlife.dat.lib.session.mci;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ONBSZ0460_O {
@GlowTrgmField(order = 1, description = "공통인사정보조회OutDto", type = "gm")
private List<CmmnPrafIfinOutDto> cmmnPrafIfinOutDto;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class CmmnPrafIfinOutDto {
@GlowTrgmField(order = 1, length = 8, description = "인사번호")
private String prafNo;
@GlowTrgmField(order = 2, length = 2, description = "인사유형코드")
private String prafTypeCd;
@GlowTrgmField(order = 3, length = 200, description = "인사명")
private String prafNm;
@GlowTrgmField(order = 4, length = 100, description = "인사영문명")
private String prafEngNm;
@GlowTrgmField(order = 5, length = 20, description = "생년월일")
private String birymd;
@GlowTrgmField(order = 6, length = 1, description = "성별코드")
private String gndrCd;
@GlowTrgmField(order = 7, length = 50, description = "주민등록번호")
private String rdreNo;
@GlowTrgmField(order = 8, length = 2, description = "조직분류코드")
private String ognzAsrtCd;
@GlowTrgmField(order = 9, length = 0, description = "TODO 전문 명세 매핑")
private String reserved09;
@GlowTrgmField(order = 10, length = 0, description = "TODO 전문 명세 매핑")
private String reserved10;
@GlowTrgmField(order = 11, length = 0, description = "TODO 전문 명세 매핑")
private String reserved11;
@GlowTrgmField(order = 12, length = 0, description = "TODO 전문 명세 매핑")
private String reserved12;
@GlowTrgmField(order = 13, length = 0, description = "TODO 전문 명세 매핑")
private String reserved13;
@GlowTrgmField(order = 14, length = 0, description = "위촉구분코드 - 길이 확인 필요")
private String appnSccd;
@GlowTrgmField(order = 15, length = 2, description = "위촉경력구분코드")
private String appnCareSccd;
@GlowTrgmField(order = 16, length = 2, description = "위해촉최종상태코드")
private String aprdLsstCd;
@GlowTrgmField(order = 17, length = 20, description = "위촉일자")
private String appnYmd;
@GlowTrgmField(order = 18, length = 20, description = "부임일자")
private String acsoYmd;
@GlowTrgmField(order = 19, length = 20, description = "해임일자")
private String dmssYmd;
@GlowTrgmField(order = 20, length = 20, description = "해촉일자")
private String dsapYmd;
@GlowTrgmField(order = 21, length = 20, description = "재위촉일자")
private String reapYmd;
@GlowTrgmField(order = 22, length = 3, description = "영업직책코드")
private String bsduCd;
@GlowTrgmField(order = 23, length = 10, description = "생명보험협회등록번호")
private String klirNo;
@GlowTrgmField(order = 24, length = 2, description = "신인등급코드")
private String nwfaGrdCd;
@GlowTrgmField(order = 25, length = 2, description = "영업자격코드")
private String bsquCd;
@GlowTrgmField(order = 26, length = 6, description = "자격심사년월")
private String qlfcIspaYm;
@GlowTrgmField(order = 27, length = 2, description = "인사등급코드")
private String prafGrdCd;
@GlowTrgmField(order = 28, length = 0, description = "인사조직분류코드 - 길이 확인 필요")
private String psmrAsrtCd;
@GlowTrgmField(order = 29, length = 0, description = "영업규정분류코드 - 길이 확인 필요")
private String sbsnRulpAsrtCd;
@GlowTrgmField(order = 30, length = 0, description = "조직레벨코드 - 길이 확인 필요")
private String ognzLeveCd;
@GlowTrgmField(order = 31, length = 0, description = "지점번호 - 길이 확인 필요")
private String brafNo;
@GlowTrgmField(order = 32, length = 0, description = "TODO 전문 명세 매핑")
private String reserved32;
@GlowTrgmField(order = 33, length = 0, description = "TODO 전문 명세 매핑")
private String reserved33;
@GlowTrgmField(order = 34, length = 0, description = "TODO 전문 명세 매핑")
private String reserved34;
@GlowTrgmField(order = 35, length = 0, description = "TODO 전문 명세 매핑")
private String reserved35;
@GlowTrgmField(order = 36, length = 0, description = "TODO 전문 명세 매핑")
private String reserved36;
@GlowTrgmField(order = 37, length = 0, description = "TODO 전문 명세 매핑")
private String reserved37;
@GlowTrgmField(order = 38, length = 0, description = "TODO 전문 명세 매핑")
private String reserved38;
@GlowTrgmField(order = 39, length = 0, description = "TODO 전문 명세 매핑")
private String reserved39;
@GlowTrgmField(order = 40, length = 0, description = "TODO 전문 명세 매핑")
private String reserved40;
@GlowTrgmField(order = 41, length = 0, description = "TODO 전문 명세 매핑")
private String reserved41;
@GlowTrgmField(order = 42, length = 0, description = "TODO 전문 명세 매핑")
private String reserved42;
@GlowTrgmField(order = 43, length = 0, description = "TODO 전문 명세 매핑")
private String reserved43;
@GlowTrgmField(order = 44, length = 0, description = "TODO 전문 명세 매핑")
private String reserved44;
@GlowTrgmField(order = 45, length = 0, description = "TODO 전문 명세 매핑")
private String reserved45;
@GlowTrgmField(order = 46, length = 0, description = "TODO 전문 명세 매핑")
private String reserved46;
@GlowTrgmField(order = 47, length = 0, description = "TODO 전문 명세 매핑")
private String reserved47;
@GlowTrgmField(order = 48, length = 0, description = "TODO 전문 명세 매핑")
private String reserved48;
@GlowTrgmField(order = 49, length = 0, description = "TODO 전문 명세 매핑")
private String reserved49;
@GlowTrgmField(order = 50, length = 0, description = "TODO 전문 명세 매핑")
private String reserved50;
@GlowTrgmField(order = 51, length = 0, description = "TODO 전문 명세 매핑")
private String reserved51;
@GlowTrgmField(order = 52, length = 0, description = "TODO 전문 명세 매핑")
private String reserved52;
@GlowTrgmField(order = 53, length = 0, description = "TODO 전문 명세 매핑")
private String reserved53;
@GlowTrgmField(order = 54, length = 0, description = "TODO 전문 명세 매핑")
private String reserved54;
@GlowTrgmField(order = 55, length = 0, description = "TODO 전문 명세 매핑")
private String reserved55;
@GlowTrgmField(order = 56, length = 0, description = "TODO 전문 명세 매핑")
private String reserved56;
@GlowTrgmField(order = 57, length = 0, description = "TODO 전문 명세 매핑")
private String reserved57;
@GlowTrgmField(order = 58, length = 0, description = "TODO 전문 명세 매핑")
private String reserved58;
@GlowTrgmField(order = 59, length = 0, description = "TODO 전문 명세 매핑")
private String reserved59;
@GlowTrgmField(order = 60, length = 0, description = "TODO 전문 명세 매핑")
private String reserved60;
@GlowTrgmField(order = 61, length = 0, description = "TODO 전문 명세 매핑")
private String reserved61;
@GlowTrgmField(order = 62, length = 0, description = "TODO 전문 명세 매핑")
private String reserved62;
@GlowTrgmField(order = 63, length = 0, description = "TODO 전문 명세 매핑")
private String reserved63;
@GlowTrgmField(order = 64, length = 0, description = "TODO 전문 명세 매핑")
private String reserved64;
@GlowTrgmField(order = 65, length = 0, description = "TODO 전문 명세 매핑")
private String reserved65;
@GlowTrgmField(order = 66, length = 0, description = "TODO 전문 명세 매핑")
private String reserved66;
@GlowTrgmField(order = 67, length = 0, description = "TODO 전문 명세 매핑")
private String reserved67;
@GlowTrgmField(order = 68, length = 0, description = "TODO 전문 명세 매핑")
private String reserved68;
@GlowTrgmField(order = 69, length = 0, description = "TODO 전문 명세 매핑")
private String reserved69;
@GlowTrgmField(order = 70, length = 0, description = "TODO 전문 명세 매핑")
private String reserved70;
@GlowTrgmField(order = 71, length = 0, description = "TODO 전문 명세 매핑")
private String reserved71;
@GlowTrgmField(order = 72, length = 0, description = "TODO 전문 명세 매핑")
private String reserved72;
@GlowTrgmField(order = 73, length = 0, description = "TODO 전문 명세 매핑")
private String reserved73;
@GlowTrgmField(order = 74, length = 0, description = "TODO 전문 명세 매핑")
private String reserved74;
@GlowTrgmField(order = 75, length = 0, description = "TODO 전문 명세 매핑")
private String reserved75;
@GlowTrgmField(order = 76, length = 0, description = "TODO 전문 명세 매핑")
private String reserved76;
@GlowTrgmField(order = 77, length = 0, description = "TODO 전문 명세 매핑")
private String reserved77;
@GlowTrgmField(order = 78, length = 0, description = "TODO 전문 명세 매핑")
private String reserved78;
}
}

View File

@@ -15,10 +15,6 @@ public class PodScaffolder {
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
System.out.println("=========================================");
System.out.println(" MCP Tool Pod Scaffolder (Java CLI) ");
System.out.println("=========================================\n");
String rawModuleName = getOrAsk(args, 0, scanner, "1. 생성할 모듈(Pod) 이름 (예: payment 또는 dat-was-payment): ");
String moduleName = rawModuleName.startsWith("dat-was-") ? rawModuleName : "dat-was-" + rawModuleName;
String portStr = getOrAsk(args, 1, scanner, "2. 사용할 포트 번호 (예: 8085): ");
@@ -33,14 +29,12 @@ public class PodScaffolder {
if (createDate.trim().isEmpty()) createDate = defaultDate;
String result = scaffoldPod(moduleName, portStr, shortName, author, createDate);
System.out.println(result);
}
private static String getOrAsk(String[] args, int index, Scanner scanner, String prompt) {
if (args.length > index) {
return args[index];
}
System.out.print(prompt);
return scanner.nextLine().trim();
}

View File

@@ -187,7 +187,9 @@ public class ToolScaffolder {
writeStructuredFieldTypes(ioDir, ioPackage + ".io", tool.interfaceId() + "_O", tool.outputFields());
String sysCode = tool.clientSystemCode();
if (sysCode != null && sysCode.length() == 4) {
String clientCap = toPascalCase(sysCode);
String normalizedSysCode = sysCode.toLowerCase(Locale.ROOT);
String clientCap = normalizedSysCode.substring(0, 1).toUpperCase(Locale.ROOT)
+ normalizedSysCode.substring(1);
writeUtf8(clientDir.resolve("Mci" + clientCap + "Client.java"),
"package " + ioPackage + ";\n\nimport io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;\nimport io.shinhanlife.glow.communication.dto.Transfer;\nimport lombok.RequiredArgsConstructor;\nimport org.springframework.stereotype.Component;\n\n@Component\n@RequiredArgsConstructor\npublic class Mci" + clientCap + "Client {\n private final AxhubMciComponent mci;\n\n public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) throws Exception {\n return mci.callTo(interfaceId, dummy, mciReq, resType);\n }\n}\n");
} else {
@@ -615,6 +617,9 @@ public class ToolScaffolder {
title = title == null || title.isBlank() ? baseName : title.trim();
description = description == null ? "" : description.trim();
httpApiName = httpApiName == null || httpApiName.isBlank() ? toKebabCase(baseName) : httpApiName.trim();
if (definitionOptions == null) {
definitionOptions = new ToolDefinitionOptions(null, null, null, null, null, null, null, null);
}
String envSourceDir = System.getProperty("AXHUB_SOURCE_DIR");
if (envSourceDir == null) {
envSourceDir = System.getenv("AXHUB_SOURCE_DIR");

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dat.lib.annotation.McpOutputSchema;
import io.shinhanlife.dat.lib.annotation.GrowToolHint;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
@@ -66,13 +67,25 @@ public class ToolSchemaResolver {
: location;
ClassPathResource resource = new ClassPathResource(path);
if (!resource.exists()) {
throw new IllegalStateException("MCP schema resource not found: " + location);
throw new ToolSchemaResourceException("MCP schema resource not found: " + location);
}
try (InputStream inputStream = resource.getInputStream()) {
return objectMapper.readValue(inputStream, new TypeReference<>() { });
} catch (Exception e) {
throw new IllegalStateException("Failed to load MCP schema resource: " + location, e);
} catch (IOException e) {
throw new ToolSchemaResourceException("Failed to load MCP schema resource: " + location, e);
}
}
public static final class ToolSchemaResourceException extends IllegalStateException {
private static final long serialVersionUID = 1L;
public ToolSchemaResourceException(String message) {
super(message);
}
public ToolSchemaResourceException(String message, IOException cause) {
super(message, cause);
}
}
}

View File

@@ -8,13 +8,6 @@ public final class McpToolNameValidationRunner {
private McpToolNameValidationRunner() {
}
public static void main(String[] args) {
if (args.length != 1) {
throw new IllegalArgumentException("Usage: McpToolNameValidationRunner <project-root>");
}
validate(Path.of(args[0]));
}
static void validate(Path projectRoot) {
McpToolNameValidator.assertUnique(projectRoot);
}

View File

@@ -34,15 +34,24 @@ public class BusinessToolController {
@PostMapping("/mcp/{name}")
public ResponseEntity<?> executeDynamicTool(
@PathVariable("name") String functionName,
@RequestHeader(value = "x-request-id", required = false) String requestId,
@RequestHeader(value = "guid", required = false) String guid,
@RequestHeader(value = "X-Guid", required = false) String guid,
@RequestHeader(value = "X-Praf-No", required = false) String prafNo,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestHeader(value = "X-Request-Time", required = false) String requestTime,
@RequestHeader(value = "X-Vrtl-Praf-No", required = false) String vrtlPrafNo,
@RequestHeader(value = "X-App-Code", required = false) String appCode,
@RequestHeader(value = "X-Project-Code", required = false) String projectCode,
@RequestHeader(value = "X-User-Ip", required = false) String userIp,
@RequestHeader(value = "X-Caller-Ip", required = false) String callerIp,
@RequestHeader(value = "X-Caller-Host", required = false) String callerHost,
@RequestHeader(value = "X-Channel", required = false) String channel,
@RequestHeader(value = "X-Agent-Id", required = false) String agentId,
@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(requestId, guid, mcpSessionId, employeeNo, virtualEmployeeNo),
new McpRequestHeaders(guid, prafNo, requestId, requestTime, vrtlPrafNo, appCode,
projectCode, userIp, callerIp, callerHost, channel, agentId, mcpSessionId),
arguments);
ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode());
result.headers().forEach(response::header);

View File

@@ -69,7 +69,7 @@
</aside>
<section class="stack">
<section class="card"><h2>2. 요청 JSON</h2><div class="notice">필수값과 형식은 Tool의 inputSchema 기준입니다. MCI·외부 연동 Tool은 업무에 맞는 테스트 데이터를 입력한 후 저장하세요.</div><textarea id="arguments" spellcheck="false" aria-label="요청 JSON"></textarea><div class="buttons" style="margin-top:12px"><button id="saveButton">현재 요청 저장</button><button class="primary" id="executeButton">실행</button></div></section>
<section class="card"><h2>3. 실행 결과</h2><div class="meta"><span class="badge" id="httpStatus">대기</span><span class="badge" id="latency">-</span><span class="badge" id="traceId">guid: -</span><span class="badge" id="requestId">x-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">X-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,7 +257,25 @@
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 = 'guid: -'; $('requestId').textContent = 'x-request-id: -'; }
function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'X-Guid: -'; $('requestId').textContent = 'X-Request-Id: -'; }
function toolHeaders(guid, request, session) {
return {
'Content-Type': 'application/json',
'X-Guid': guid,
'X-Praf-No': '100001',
'X-Request-Id': request,
'X-Request-Time': new Date().toISOString(),
'X-Vrtl-Praf-No': 'V100001',
'X-App-Code': 'DATMT',
'X-Project-Code': 'AXHUB',
'X-User-Ip': '127.0.0.1',
'X-Caller-Ip': '127.0.0.1',
'X-Caller-Host': window.location.hostname || 'localhost',
'X-Channel': 'TOOL-TEST-CONSOLE',
'X-Agent-Id': 'TOOL-TEST-CONSOLE',
'mcp-session-id': session
};
}
async function execute(tool = state.selected, body = null) {
if (!tool) throw new Error('실행할 Tool을 선택하세요.');
@@ -269,13 +287,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', 'guid':guid, 'x-request-id':request, 'mcp-session-id':session },
headers: toolHeaders(guid, request, session),
body: JSON.stringify(reqPayload)
});
} else {
response = await fetch(endpointFor(tool), {
method: 'POST',
headers: { 'Content-Type':'application/json', 'guid':guid, 'x-request-id':request, 'mcp-session-id':session },
headers: toolHeaders(guid, request, session),
body: JSON.stringify(payload)
});
}
@@ -290,7 +308,7 @@
const elapsed = Math.round(performance.now() - started);
$('httpStatus').textContent = `HTTP ${displayStatus}`; $('httpStatus').className = `badge ${isOk ? 'ok' : 'fail'}`;
$('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `guid: ${response.headers.get('guid') || guid}`; $('requestId').textContent = `x-request-id: ${response.headers.get('x-request-id') || request}`;
$('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `X-Guid: ${response.headers.get('X-Guid') || guid}`; $('requestId').textContent = `X-Request-Id: ${response.headers.get('X-Request-Id') || request}`;
let displayData = data;
if (isGatewayMode && data && typeof data === 'object') {
if (data.result && data.result.result) {

View File

@@ -1,9 +1,13 @@
package io.shinhanlife.dat.lib.adapter.test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MockEimsHttpServerTest {
@@ -16,4 +20,28 @@ class MockEimsHttpServerTest {
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
assertThat(response.getBody().path("resultCode").asText()).isEqualTo("SUCCESS");
}
@Test
void returnsInternalServerErrorForMalformedMockJson() {
MockEimsHttpServer server = new MockEimsHttpServer(new ObjectMapper());
var response = server.mockToolHttpResponse("invalid_mock_response", null);
assertThat(response.getStatusCode().value()).isEqualTo(500);
}
@Test
void doesNotSwallowUnexpectedRuntimeExceptions() {
ObjectMapper failingMapper = new ObjectMapper() {
@Override
public JsonNode readTree(InputStream input) throws IOException {
throw new IllegalStateException("unexpected mapper failure");
}
};
MockEimsHttpServer server = new MockEimsHttpServer(failingMapper);
assertThatThrownBy(() -> server.mockToolHttpResponse("cmm_memo_retriever", null))
.isInstanceOf(IllegalStateException.class)
.hasMessage("unexpected mapper failure");
}
}

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.dat.lib.aop;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.shinhanlife.dat.lib.config.McpProperties;
import java.lang.reflect.Method;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.junit.jupiter.api.Test;
import org.springaicommunity.mcp.annotation.McpTool;
class ToolSlaMonitoringAspectTest {
@Test
void propagatesJvmErrorsWithoutInspectingThem() throws Throwable {
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
MethodSignature signature = mock(MethodSignature.class);
Method method = SampleTool.class.getDeclaredMethod("execute");
MessageAccessError error = new MessageAccessError();
when(joinPoint.getSignature()).thenReturn(signature);
when(signature.getMethod()).thenReturn(method);
when(joinPoint.proceed()).thenThrow(error);
MessageAccessError thrown = assertThrows(MessageAccessError.class,
() -> new ToolSlaMonitoringAspect(new McpProperties()).monitorToolSla(joinPoint));
assertSame(error, thrown);
}
static class SampleTool {
@McpTool(name = "sample_tool")
public void execute() {
}
}
static class MessageAccessError extends Error {
@Override
public String getMessage() {
throw new AssertionError("JVM errors must not be inspected by the SLA aspect");
}
}
}

View File

@@ -69,7 +69,7 @@ class AxhubHttpComponentTest {
}
@Test
void forwardsDapmsHeadersToTheConfiguredHttpService() throws Exception {
void forwardsCanonicalToolHeadersToTheConfiguredHttpService() throws Exception {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
AxhubHttpProperties properties = new AxhubHttpProperties();
@@ -79,22 +79,35 @@ class AxhubHttpComponentTest {
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("X-Guid", "guid-1"))
.andExpect(header("X-Praf-No", "praf-1"))
.andExpect(header("X-Request-Id", "request-1"))
.andExpect(header("X-Request-Time", "2026-08-25T12:34:56+09:00"))
.andExpect(header("X-Vrtl-Praf-No", "virtual-1"))
.andExpect(header("X-App-Code", "app-1"))
.andExpect(header("X-Project-Code", "project-1"))
.andExpect(header("X-User-Ip", "10.0.0.1"))
.andExpect(header("X-Caller-Ip", "10.0.0.2"))
.andExpect(header("X-Caller-Host", "caller.example.test"))
.andExpect(header("X-Channel", "MCP"))
.andExpect(header("X-Agent-Id", "agent-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)")));
setRequestHeaders(headers(Map.ofEntries(
Map.entry("guid", "guid-1"),
Map.entry("prafNo", "praf-1"),
Map.entry("requestId", "request-1"),
Map.entry("requestTime", "2026-08-25T12:34:56+09:00"),
Map.entry("vrtlPrafNo", "virtual-1"),
Map.entry("appCode", "app-1"),
Map.entry("projectCode", "project-1"),
Map.entry("userIp", "10.0.0.1"),
Map.entry("callerIp", "10.0.0.2"),
Map.entry("callerHost", "caller.example.test"),
Map.entry("channel", "MCP"),
Map.entry("agentId", "agent-1"),
Map.entry("mcpSessionId", "session-1"))));
try {
assertThat(component.call("status", Map.of(), SampleResponse.class).status()).isEqualTo("OK");
server.verify();

View File

@@ -0,0 +1,28 @@
package io.shinhanlife.dat.lib.integration.mci.component;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.shinhanlife.dat.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.module.mci.component.GlowMciComponent;
import org.junit.jupiter.api.Test;
class AxhubMciComponentTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void doesNotReclassifyUnexpectedRuntimeFailuresAsBusinessFailures() {
GlowMciComponent glowMci = mock(GlowMciComponent.class);
IllegalStateException failure = new IllegalStateException("unexpected MCI adapter defect");
when(glowMci.sync(any())).thenThrow(failure);
AxhubMciComponent component = new AxhubMciComponent(glowMci, new GlowCommunicationProperties());
IllegalStateException thrown = assertThrows(IllegalStateException.class, () ->
component.callTo("ONBSA0790", "ONBSA", new Object(), Object.class));
assertSame(failure, thrown);
}
}

View File

@@ -1,6 +1,11 @@
package io.shinhanlife.dat.lib.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
@@ -29,23 +34,72 @@ class McpToolExecutionServiceTest {
void convertsArgumentsToDtoAndExecutesTheMatchedTool() {
McpToolExecutionService service = serviceWith(new EchoTool());
ToolExecutionResult result = service.execute("sample.cmm.value.echo",
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")),
headers(Map.ofEntries(
Map.entry("guid", "guid-1"),
Map.entry("prafNo", "praf-1"),
Map.entry("requestId", "request-1"),
Map.entry("requestTime", "2026-08-25T12:34:56+09:00"),
Map.entry("vrtlPrafNo", "virtual-1"),
Map.entry("appCode", "app-1"),
Map.entry("projectCode", "project-1"),
Map.entry("userIp", "10.0.0.1"),
Map.entry("callerIp", "10.0.0.2"),
Map.entry("callerHost", "caller.example.test"),
Map.entry("channel", "MCP"),
Map.entry("agentId", "agent-1"),
Map.entry("mcpSessionId", "session-1"))),
Map.of("value", "hello"));
assertEquals(200, result.statusCode());
assertEquals("hello", ((Map<?, ?>) result.body()).get("value"));
assertEquals("request-1", result.headers().get("x-request-id"));
assertEquals("guid-1", result.headers().get("guid"));
assertEquals("request-1", result.headers().get("X-Request-Id"));
assertEquals("guid-1", result.headers().get("X-Guid"));
assertEquals("session-1", result.headers().get("mcp-session-id"));
}
@Test
void doesNotHideUnexpectedInputSchemaResolverFailures() {
ToolSchemaResolver resolver = mock(ToolSchemaResolver.class);
when(resolver.resolve(any(), any(), any()))
.thenThrow(new IllegalStateException("unexpected resolver defect"));
McpToolExecutionService service = serviceWith(new EchoTool(), resolver);
assertThrows(IllegalStateException.class, () ->
service.execute("sample.cmm.value.echo", null, Map.of("value", "hello")));
}
@Test
void doesNotHideUnexpectedOutputSchemaResolverFailures() {
ToolSchemaResolver resolver = mock(ToolSchemaResolver.class);
when(resolver.resolveOutput(any(), any(), any()))
.thenThrow(new IllegalStateException("unexpected resolver defect"));
McpToolExecutionService service = serviceWith(new MapTool(), resolver);
assertThrows(IllegalStateException.class, () ->
service.execute("sample.cmm.map.echo", null, Map.of("value", "hello")));
}
@Test
void propagatesUnexpectedRuntimeFailuresThrownByTheTool() {
UnexpectedToolFailure failure = new UnexpectedToolFailure();
McpToolExecutionService service = serviceWith(new RuntimeFailureTool(failure));
UnexpectedToolFailure thrown = assertThrows(UnexpectedToolFailure.class, () ->
service.execute("sample.cmm.runtime.failure", null, Map.of()));
assertSame(failure, thrown);
}
@Test
void propagatesJvmErrorsThrownByTheTool() {
ToolJvmError error = new ToolJvmError();
McpToolExecutionService service = serviceWith(new ErrorTool(error));
ToolJvmError thrown = assertThrows(ToolJvmError.class, () ->
service.execute("sample.cmm.error.failure", null, Map.of()));
assertSame(error, thrown);
}
private McpRequestHeaders headers(Map<String, String> values) {
try {
Class<?>[] types = Arrays.stream(McpRequestHeaders.class.getRecordComponents())
@@ -61,6 +115,11 @@ class McpToolExecutionServiceTest {
}
private McpToolExecutionService serviceWith(Object toolBean) {
ObjectMapper objectMapper = new ObjectMapper();
return serviceWith(toolBean, new ToolSchemaResolver(objectMapper));
}
private McpToolExecutionService serviceWith(Object toolBean, ToolSchemaResolver resolver) {
ObjectMapper objectMapper = new ObjectMapper();
StaticApplicationContext context = new StaticApplicationContext();
context.getBeanFactory().registerSingleton("toolBean", toolBean);
@@ -69,7 +128,7 @@ class McpToolExecutionServiceTest {
registry.initialize();
return new McpToolExecutionService(registry, objectMapper,
new ToolArgumentSchemaValidator(objectMapper, new DefaultJsonSchemaValidator()),
new ToolSchemaResolver(objectMapper));
resolver);
}
static class EchoTool {
@@ -80,6 +139,45 @@ class McpToolExecutionServiceTest {
}
}
static class MapTool {
@McpTool(name = "sample.cmm.map.echo", description = "Echoes a map")
public Map<String, Object> execute(Map<String, Object> request) {
return request;
}
}
static class RuntimeFailureTool {
private final UnexpectedToolFailure failure;
RuntimeFailureTool(UnexpectedToolFailure failure) {
this.failure = failure;
}
@McpTool(name = "sample.cmm.runtime.failure")
public Map<String, Object> execute(Map<String, Object> request) {
throw failure;
}
}
static class ErrorTool {
private final ToolJvmError error;
ErrorTool(ToolJvmError error) {
this.error = error;
}
@McpTool(name = "sample.cmm.error.failure")
public Map<String, Object> execute(Map<String, Object> request) {
throw error;
}
}
static class UnexpectedToolFailure extends RuntimeException {
}
static class ToolJvmError extends Error {
}
static class EchoRequest {
@McpToolParam(description = "Value", required = true)
private String value;

View File

@@ -0,0 +1,134 @@
package io.shinhanlife.dat.lib.session.mci;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.dat.lib.session.dto.SessionDto;
import io.shinhanlife.glow.BizException;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import io.shinhanlife.glow.communication.dto.Transfer;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class AxhubSessionServiceTest {
private static final String PACKAGE = "io.shinhanlife.dat.lib.session.mci.";
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void fetchesSessionThroughOnbsz0460ListContract() throws Exception {
Class<?> requestClass = requiredClass("ONBSZ0460_I");
Class<?> responseClass = requiredClass("ONBSZ0460_O");
Class<?> outputClass = requiredClass("ONBSZ0460_O$CmmnPrafIfinOutDto");
Class<?> clientClass = requiredClass("MciOnbszClient");
Object output = outputClass.getDeclaredConstructor().newInstance();
set(outputClass, output, "PrafNo", "1234567");
set(outputClass, output, "OgnzAsrtCd", "ORG001");
set(outputClass, output, "BrafNo", "BR001");
set(outputClass, output, "PsmrAsrtCd", "PSMR01");
set(outputClass, output, "SbsnRulpAsrtCd", "RULE01");
set(outputClass, output, "BsduCd", "BSD");
set(outputClass, output, "BsquCd", "BQ");
set(outputClass, output, "OgnzLeveCd", "LEVEL1");
Object response = responseClass.getDeclaredConstructor().newInstance();
responseClass.getMethod("setCmmnPrafIfinOutDto", List.class).invoke(response, List.of(output));
Transfer transfer = mock(Transfer.class);
when(transfer.getBody()).thenReturn(response);
AxhubMciComponent mci = mock(AxhubMciComponent.class);
when(mci.callTo(eq("ONBSZ0460"), eq("ONBSZ"), any(), eq((Class) responseClass)))
.thenReturn(transfer);
Object client = clientClass.getDeclaredConstructor(AxhubMciComponent.class).newInstance(mci);
AxhubSessionService service = AxhubSessionService.class.getDeclaredConstructor(clientClass).newInstance(client);
SessionDto session = service.fetchUserSession("1234567");
assertEquals("1234567", session.getPrafNo());
assertEquals("ORG001", session.getOgnzNo());
assertEquals("BR001", session.getBrafNo());
ArgumentCaptor<Object> requestCaptor = ArgumentCaptor.forClass(Object.class);
verify(mci).callTo(eq("ONBSZ0460"), eq("ONBSZ"), requestCaptor.capture(), eq((Class) responseClass));
Object request = requestCaptor.getValue();
assertEquals(requestClass, request.getClass());
List<?> inputs = (List<?>) requestClass.getMethod("getCmmnPrafIfinInDto").invoke(request);
assertEquals(1, inputs.size());
assertEquals("1234567", inputs.get(0).getClass().getMethod("getPrafNo").invoke(inputs.get(0)));
}
@Test
void exposesSeventyEightOutputMappingSlots() {
Class<?> outputClass = requiredClass("ONBSZ0460_O$CmmnPrafIfinOutDto");
Field[] fields = outputClass.getDeclaredFields();
assertEquals(78, fields.length);
List<Integer> actualOrders = Arrays.stream(fields)
.map(field -> field.getAnnotation(GlowTrgmField.class))
.map(GlowTrgmField::order)
.sorted()
.toList();
assertEquals(IntStream.rangeClosed(1, 78).boxed().toList(), actualOrders);
}
@Test
void removesOldOnbsa0790Types() {
assertThrows(ClassNotFoundException.class, () -> Class.forName(PACKAGE + "ONBSA0790_I"));
assertThrows(ClassNotFoundException.class, () -> Class.forName(PACKAGE + "ONBSA0790_O"));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void returnsNullWhenMciReportsABusinessCommunicationFailure() throws Exception {
Class<?> responseClass = requiredClass("ONBSZ0460_O");
Class<?> clientClass = requiredClass("MciOnbszClient");
AxhubMciComponent mci = mock(AxhubMciComponent.class);
when(mci.callTo(eq("ONBSZ0460"), eq("ONBSZ"), any(), eq((Class) responseClass)))
.thenThrow(bizException());
Object client = clientClass.getDeclaredConstructor(AxhubMciComponent.class).newInstance(mci);
AxhubSessionService service = AxhubSessionService.class.getDeclaredConstructor(clientClass).newInstance(client);
assertNull(service.fetchUserSession("1234567"));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void doesNotHideUnexpectedRuntimeFailures() throws Exception {
Class<?> responseClass = requiredClass("ONBSZ0460_O");
Class<?> clientClass = requiredClass("MciOnbszClient");
AxhubMciComponent mci = mock(AxhubMciComponent.class);
when(mci.callTo(eq("ONBSZ0460"), eq("ONBSZ"), any(), eq((Class) responseClass)))
.thenThrow(new IllegalStateException("unexpected defect"));
Object client = clientClass.getDeclaredConstructor(AxhubMciComponent.class).newInstance(mci);
AxhubSessionService service = AxhubSessionService.class.getDeclaredConstructor(clientClass).newInstance(client);
assertThrows(IllegalStateException.class, () -> service.fetchUserSession("1234567"));
}
private Class<?> requiredClass(String simpleName) {
return assertDoesNotThrow(() -> Class.forName(PACKAGE + simpleName));
}
private void set(Class<?> type, Object target, String property, String value) throws Exception {
Method setter = type.getMethod("set" + property, String.class);
setter.invoke(target, value);
}
private BizException bizException() {
return new BizException("CST00477", new String[]{"MCI communication failure"},
new RuntimeException("connection closed"));
}
}

View File

@@ -1,9 +1,5 @@
package io.shinhanlife.dat.lib.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.shinhanlife.dat.lib.metadata.ToolDefinition;
import io.shinhanlife.dat.lib.metadata.ToolDefinitionValidator;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -42,13 +38,13 @@ class ToolScaffolderTest {
Path sourceRoot = root.resolve("dat-was-customer/src/main/java/io/shinhanlife/dat/mcc");
String useCase = Files.readString(sourceRoot.resolve("biz/cmm/usecase/CustomerUseCase.java"));
String implementation = Files.readString(sourceRoot.resolve("biz/cmm/usecase/impl/CustomerUseCaseImpl.java"));
String guidanceClient = Files.readString(sourceRoot.resolve("infra/itrf/mci/nild/CustomerGuidanceClient.java"));
String guidanceClient = Files.readString(sourceRoot.resolve("infra/itrf/mci/nil/d/MciNildClient.java"));
assertTrue(useCase.contains("CustomerGuidanceResponse searchGuidance(CustomerGuidanceRequest req)"), useCase);
assertTrue(useCase.contains("CustomerContractResponse searchContract(CustomerContractRequest req)"), useCase);
assertTrue(implementation.contains("private final CustomerGuidanceClient customerGuidanceClient;"), implementation);
assertTrue(implementation.contains("customerGuidanceClient.callCustomerGuidance(request)"), implementation);
assertTrue(guidanceClient.contains("CustomerGuidance_O callCustomerGuidance(CustomerGuidance_I request)"), guidanceClient);
assertTrue(implementation.contains("private final MciNildClient mciNildClient;"), implementation);
assertTrue(implementation.contains("mciNildClient.callTo(\"CTMNILO00007\", null, request, CTMNILO00007_O.class)"), implementation);
assertTrue(guidanceClient.contains("class MciNildClient"), guidanceClient);
}
@Test
@@ -85,7 +81,6 @@ class ToolScaffolderTest {
Path dtoRoot = root.resolve("dat-was-claim/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/dto");
String request = Files.readString(dtoRoot.resolve("ClaimSearchRequest.java"));
String response = Files.readString(dtoRoot.resolve("ClaimSearchResponse.java"));
String definition = Files.readString(root.resolve("dat-was-claim/src/main/resources/tool-definitions/cmm/cmm_claim_search.yml"));
String mock = Files.readString(root.resolve("dat-was-claim/src/main/resources/mock-responses/cmm_claim_search.json"));
assertTrue(request.contains("private ClaimStatus claimStatus;"), request);
@@ -94,8 +89,6 @@ class ToolScaffolderTest {
assertTrue(response.contains("private List<GuidanceItemsItem> guidanceItems;"), response);
assertTrue(response.contains("public static class GuidanceItemsItem"), response);
assertFalse(Files.exists(dtoRoot.resolve("ClaimSearchResponseGuidanceItemsItem.java")));
assertTrue(definition.contains("enum: [OPEN, CLOSED]"), definition);
assertTrue(definition.contains("type: array"), definition);
assertTrue(mock.contains("\"guidanceItems\" : [{"), mock);
}
@@ -140,7 +133,7 @@ class ToolScaffolderTest {
}
@Test
void generatesV17ToolDefinitionTogetherWithToolSources() throws Exception {
void generatesGrowToolHintMetadataTogetherWithToolSources() throws Exception {
String moduleName = root.resolve("dat-was-v17-definition").toString();
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회",
@@ -149,16 +142,13 @@ class ToolScaffolderTest {
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", List.of("09860000"), "", true)),
List.of(), "employee");
Path definition = root.resolve("dat-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml");
String yaml = Files.readString(definition);
Path useCasePath = root.resolve("dat-was-v17-definition/src/main/java/io/shinhanlife/dat/mcc/biz/smp/usecase/EmployeeSearchUseCase.java");
String useCase = Files.readString(useCasePath);
assertTrue(yaml.contains("name: smp_employee_search"), yaml);
assertTrue(yaml.contains("when_to_use:"), yaml);
assertTrue(yaml.contains("example_queries:"), yaml);
assertTrue(yaml.contains("additionalProperties: false"), yaml);
assertTrue(yaml.contains("owner_org: \"MCP_TOOL\""), yaml);
ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(yaml, ToolDefinition.class);
ToolDefinitionValidator.validate(parsed, definition.toString());
assertTrue(useCase.contains("name = \"smp_employee_search\""), useCase);
assertTrue(useCase.contains("whenToUse = \"사용자가 이 업무 기능의 실행 또는 조회를 요청할 때 사용합니다.\""), useCase);
assertTrue(useCase.contains("exampleQueries = {\"직원 조회 정보를 보여줘\""), useCase);
assertTrue(useCase.contains("ownerOrg = \"MCP_TOOL\""), useCase);
}
@Test
@@ -178,12 +168,12 @@ class ToolScaffolderTest {
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", List.of("10001"), "", true)),
List.of(), "employee", options);
Path definition = root.resolve("dat-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml");
ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(Files.readString(definition), ToolDefinition.class);
assertEquals("HR_TEAM", parsed.ownerOrg());
assertEquals("10001 직원을 찾아줘", parsed.exampleQueries().get(2));
assertEquals(2, parsed.tags().size());
ToolDefinitionValidator.validate(parsed, definition.toString());
Path useCasePath = root.resolve("dat-was-v17-options/src/main/java/io/shinhanlife/dat/mcc/biz/smp/usecase/EmployeeSearchUseCase.java");
String useCase = Files.readString(useCasePath);
assertTrue(useCase.contains("functionDescription = \"사번으로 직원을 조회한다.\""), useCase);
assertTrue(useCase.contains("exampleQueries = {\"사번 10001을 조회해줘\", \"직원 10001 소속을 알려줘\", \"10001 직원을 찾아줘\"}"), useCase);
assertTrue(useCase.contains("tags = {\"employee\", \"search\"}"), useCase);
assertTrue(useCase.contains("ownerOrg = \"HR_TEAM\""), useCase);
}
@TempDir
@@ -201,7 +191,9 @@ class ToolScaffolderTest {
String response = Files.readString(root.resolve("dto/ClaimSearchResponse.java"));
assertTrue(useCase.contains("name = \"cmm_claim_search\""));
assertTrue(useCase.contains("@GrowToolHint(register = true, categoryKey = \"cmm\", mappingId = \"CLM0001\")"));
assertTrue(useCase.contains("requiresApproval = false"), useCase);
assertTrue(useCase.contains("categoryKey = \"cmm\""), useCase);
assertTrue(useCase.contains("mappingId = \"CLM0001\""), useCase);
assertTrue(response.contains("private String resultCode;"));
assertTrue(response.contains("private String resultMessage;"));
}
@@ -231,9 +223,10 @@ class ToolScaffolderTest {
Path schemas = root.resolve("dat-was-sample/src/main/resources/tool-schemas/cmm");
assertTrue(Files.exists(schemas.resolve("claim-search-resource-input-schema.json")));
assertTrue(Files.exists(schemas.resolve("claim-search-resource-output-schema.json"))); String useCase = Files.readString(root.resolve("dat-was-sample/src/main/java/io/shinhanlife/dat/mcc/biz/cmm/usecase/ClaimSearchUseCase.java"));
assertTrue(useCase.contains("@GrowToolHint(register = true, categoryKey = \"cmm\", mappingId = \"CLM0001\","));
assertTrue(useCase.contains("inputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-input-schema.json\""));
assertTrue(useCase.contains("outputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-output-schema.json\""));
assertTrue(useCase.contains("categoryKey = \"cmm\""), useCase);
assertTrue(useCase.contains("mappingId = \"CLM0001\""), useCase);
assertTrue(useCase.contains("inputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-input-schema.json\""), useCase);
assertTrue(useCase.contains("outputSchemaResource = \"classpath:tool-schemas/cmm/claim-search-resource-output-schema.json\""), useCase);
}
@Test
@@ -272,12 +265,14 @@ class ToolScaffolderTest {
Path sourceRoot = root.resolve("dat-was-pay/src/main/java/io/shinhanlife/dat/mcc");
String request = Files.readString(sourceRoot.resolve("biz/pay/dto/SearchHrRequest.java"));
String response = Files.readString(sourceRoot.resolve("biz/pay/dto/SearchHrResponse.java"));
String mciRequest = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfag/io/SHEARCH_01_I.java"));
String mciResponse = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfag/io/SHEARCH_01_O.java"));
String mciRequest = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfa/g/io/SHEARCH_01_I.java"));
String mciResponse = Files.readString(sourceRoot.resolve("infra/itrf/mci/dfa/g/io/SHEARCH_01_O.java"));
String converter = Files.readString(sourceRoot.resolve("biz/pay/converter/SearchHrConverter.java")); String useCase = Files.readString(sourceRoot.resolve("biz/pay/usecase/SearchHrUseCase.java"));
String implementation = Files.readString(sourceRoot.resolve("biz/pay/usecase/impl/SearchHrUseCaseImpl.java"));
assertTrue(mciRequest.contains("package io.shinhanlife.dat.mcc.infra.itrf.mci.dfag.io;"), mciRequest); assertTrue(useCase.contains("@GrowToolHint(register = true, categoryKey = \"pay\", mappingId = \"SHEARCH_01\")"));
assertTrue(implementation.contains("import io.shinhanlife.dat.mcc.infra.itrf.mci.dfag.io.SHEARCH_01_O;"));
assertTrue(mciRequest.contains("package io.shinhanlife.dat.mcc.infra.itrf.mci.dfa.g.io;"), mciRequest);
assertTrue(useCase.contains("categoryKey = \"pay\""), useCase);
assertTrue(useCase.contains("mappingId = \"SHEARCH_01\""), useCase);
assertTrue(implementation.contains("import io.shinhanlife.dat.mcc.infra.itrf.mci.dfa.g.io.SHEARCH_01_O;"));
assertTrue(request.contains("private String employeeId;"));
assertTrue(request.contains("private Integer page;"));
@@ -481,7 +476,7 @@ class ToolScaffolderTest {
String impl = Files.readString(sourceRoot.resolve("biz/cmm/usecase/impl/CustomerUseCaseImpl.java"));
assertTrue(useCase.contains("getProfile(CustomerProfileRequest req)"), useCase);
assertTrue(useCase.contains("getNotice(CustomerNoticeRequest req)"), useCase);
assertTrue(impl.contains("private final CustomerProfileClient customerProfileClient;"), impl);
assertTrue(impl.contains("private final MciCstmClient mciCstmClient;"), impl);
assertTrue(impl.contains("private final CustomerNoticeClient customerNoticeClient;"), impl);
}
}

View File

@@ -1,11 +1,16 @@
package io.shinhanlife.dat.lib.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
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.dat.lib.annotation.McpOutputSchema;
import io.shinhanlife.dat.lib.annotation.GrowToolHint;
import io.shinhanlife.dat.lib.util.ToolSchemaResolver.ToolSchemaResourceException;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
@@ -46,6 +51,29 @@ class ToolSchemaResolverTest {
assertTrue(schema.isEmpty());
}
@Test
void identifiesMalformedSchemaResourcesAsSchemaResourceFailures() throws Exception {
Method method = ResourceSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
ToolSchemaResourceException exception = assertThrows(ToolSchemaResourceException.class, () ->
resolver.resolve(method.getAnnotation(McpTool.class),
method.getAnnotation(GrowToolHint.class),
AutomaticRequest.class));
assertInstanceOf(IOException.class, exception.getCause());
}
@Test
void identifiesMissingSchemaResourcesAsSchemaResourceFailures() throws Exception {
Method method = MissingResourceSchemaTool.class.getDeclaredMethod("search", AutomaticRequest.class);
ToolSchemaResourceException exception = assertThrows(ToolSchemaResourceException.class, () ->
resolver.resolve(method.getAnnotation(McpTool.class),
method.getAnnotation(GrowToolHint.class), AutomaticRequest.class));
assertTrue(exception.getMessage().contains("missing-tool-schema.json"));
}
@SuppressWarnings("unchecked")
private Map<String, Object> properties(Map<String, Object> schema) {
return (Map<String, Object>) schema.get("properties");
@@ -69,6 +97,21 @@ class ToolSchemaResolverTest {
}
}
static class ResourceSchemaTool {
@McpTool(name = "oth_test_resource_schema")
@GrowToolHint(
inputSchemaResource = "classpath:schemas/malformed-tool-schema.json")
void search(AutomaticRequest request) {
}
}
static class MissingResourceSchemaTool {
@McpTool(name = "oth_test_missing_resource_schema")
@GrowToolHint(inputSchemaResource = "classpath:schemas/missing-tool-schema.json")
void search(AutomaticRequest request) {
}
}
@McpOutputSchema
static class SimpleResponse {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"SUCCESS", "FAILURE"})

View File

@@ -16,21 +16,37 @@ import org.springframework.mock.web.MockHttpServletResponse;
class McpRequestHeaderFilterTest {
@Test
void capturesDapmsHeadersOnlyForTheCurrentRequest() throws Exception {
void capturesCanonicalToolHeadersOnlyForTheCurrentRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
request.addHeader("x-request-id", "request-001");
request.addHeader("guid", "guid-001");
request.addHeader("X-Guid", "guid-001");
request.addHeader("X-Praf-No", "praf-001");
request.addHeader("X-Request-Id", "request-001");
request.addHeader("X-Request-Time", "2026-08-25T12:34:56+09:00");
request.addHeader("X-Vrtl-Praf-No", "virtual-001");
request.addHeader("X-App-Code", "app-001");
request.addHeader("X-Project-Code", "project-001");
request.addHeader("X-User-Ip", "10.0.0.1");
request.addHeader("X-Caller-Ip", "10.0.0.2");
request.addHeader("X-Caller-Host", "caller.example.test");
request.addHeader("X-Channel", "MCP");
request.addHeader("X-Agent-Id", "agent-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) ->
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)")));
Map.entry("prafNo", "praf-001"),
Map.entry("requestId", "request-001"),
Map.entry("requestTime", "2026-08-25T12:34:56+09:00"),
Map.entry("vrtlPrafNo", "virtual-001"),
Map.entry("appCode", "app-001"),
Map.entry("projectCode", "project-001"),
Map.entry("userIp", "10.0.0.1"),
Map.entry("callerIp", "10.0.0.2"),
Map.entry("callerHost", "caller.example.test"),
Map.entry("channel", "MCP"),
Map.entry("agentId", "agent-001"),
Map.entry("mcpSessionId", "session-001")));
assertNull(McpRequestHeaderContext.current());
}

View File

@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
class BusinessToolControllerHeaderContractTest {
@Test
void receivesTheHeadersForwardedByDapms() {
void receivesTheCanonicalToolHeadersAsOptionalValues() {
Method method = Arrays.stream(BusinessToolController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("executeDynamicTool"))
.findFirst()
@@ -25,6 +25,18 @@ class BusinessToolControllerHeaderContractTest {
.toList();
assertThat(headerNames).containsExactly(
"x-request-id", "guid", "mcp-session-id", "employee-no", "virtual-employee-no");
"X-Guid",
"X-Praf-No",
"X-Request-Id",
"X-Request-Time",
"X-Vrtl-Praf-No",
"X-App-Code",
"X-Project-Code",
"X-User-Ip",
"X-Caller-Ip",
"X-Caller-Host",
"X-Channel",
"X-Agent-Id",
"mcp-session-id");
}
}

View File

@@ -1,6 +1,7 @@
package io.shinhanlife.dat.mcc.presentation;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.InputStream;
@@ -18,7 +19,21 @@ class ToolTestConsoleResourceTest {
assertTrue(html.contains("/tool-manifest"));
assertTrue(html.contains("Run saved cases"));
assertTrue(html.contains("localStorage"));
assertTrue(html.contains("request-id"));
assertTrue(html.contains("X-Guid"));
assertTrue(html.contains("X-Praf-No"));
assertTrue(html.contains("X-Request-Id"));
assertTrue(html.contains("X-Request-Time"));
assertTrue(html.contains("X-Vrtl-Praf-No"));
assertTrue(html.contains("X-App-Code"));
assertTrue(html.contains("X-Project-Code"));
assertTrue(html.contains("X-User-Ip"));
assertTrue(html.contains("X-Caller-Ip"));
assertTrue(html.contains("X-Caller-Host"));
assertTrue(html.contains("X-Channel"));
assertTrue(html.contains("X-Agent-Id"));
assertFalse(html.contains("'guid':"));
assertFalse(html.contains("'employee-no':"));
assertFalse(html.contains("'virtual-employee-no':"));
}
}
}

View File

@@ -0,0 +1 @@
{ invalid json

View File

@@ -0,0 +1,4 @@
{
"type": "object",
"properties": {
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.dat.mcc.biz.pro.converter;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_I;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_O;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface PersonalCustomerDetailInquiryConverter {
// Field names differ? Add mappings like this before the method.
@Mapping(source = "customerId", target = "customerId")
DATNCSO00001_I toLegacyRequest(PersonalCustomerDetailInquiryRequest request);
PersonalCustomerDetailInquiryResponse toResponse( DATNCSO00001_O mciRes);
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dat.mcc.biz.pro.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.Pattern;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PersonalCustomerDetailInquiryRequest {
@Schema(description = "조회할 고객의 고유 ID (형식: ^[A-Za-z0-9-]{1,20}$) (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED)
@Pattern(regexp = "^[A-Za-z0-9-]{1,20}$")
private String customerId;
@Schema(description = "고객 이름 (선택적 확인용) (형식: ^[가-힣a-zA-Z\s]{2,20}$) (예시: 김철수, Lee Chul-soo)", example = "김철수")
@Pattern(regexp = "^[가-힣a-zA-Z\s]{2,20}$")
private String name;
@Schema(description = "고객 전화번호 (선택적 확인용) (형식: $) (예시: 010-1234-5678, +821012345678)", example = "010-1234-5678")
private String phoneNumber;
@Schema(description = "고객 이메일 주소 (선택적 확인용) (형식: $) (예시: customer@example.com, user@test.co.kr)", example = "customer@example.com")
private String email;
}

View File

@@ -0,0 +1,35 @@
package io.shinhanlife.dat.mcc.biz.pro.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PersonalCustomerDetailInquiryResponse {
private String resultCode;
private String resultMessage;
@Schema(description = "조회된 고객의 고유 ID (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED)
private String customerId;
@Schema(description = "고객의 전체 이름 (예시: 김철수, Lee Chul-soo)", example = "김철수", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@Schema(description = "고객의 전화번호 (예시: 010-1234-5678)", example = "010-1234-5678", requiredMode = Schema.RequiredMode.REQUIRED)
private String phoneNumber;
@Schema(description = "고객의 이메일 주소 (예시: customer@example.com)", example = "customer@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
private String email;
@Schema(description = "고객의 주소 정보 (예시: 서울시 강남구 테헤란로 123)", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "고객의 생년월일 (예시: 1990-01-15)", example = "1990-01-15")
private String birthDate;
@Schema(description = "고객의 멤버십 등급 (예시: GOLD, SILVER, BRONZE)", example = "GOLD")
private String membershipLevel;
}

View File

@@ -0,0 +1,41 @@
package io.shinhanlife.dat.mcc.biz.pro.usecase;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dat.lib.annotation.GrowToolHint;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse;
/**
* @package io.shinhanlife.dat.mcc.biz.pro.usecase
* @className PersonalCustomerDetailInquiryUseCase
* @description AX HUB 시스템 처리 클래스
* @author jade
* @create 2026.08.25
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.08.25 jade 최초생성
*
* </pre>
*/
public interface PersonalCustomerDetailInquiryUseCase {
@McpTool(name = "pro_personal_inquiry", title = "개인고객 상세 정보 조회", description = "개인고객의 상세 정보를 조회하는 도구로, 고객 ID 또는 기본 정보를 통해 이름, 연락처, 주소, 생년월일, 멤버십 등급 등 개인 고객 정보를 안전하게 조회할 수 있습니다.")
@GrowToolHint(
requiresApproval = false,
categoryKey = "pro",
mappingId = " DATNCSO00001",
functionDescription = "고객 ID 또는 기본 정보를 기반으로 개인 고객의 상세 정보를 조회하고 반환하는 핵심 비즈니스 함수입니다.",
whenToUse = "개인 고객의 상세 정보가 필요한 경우, 예를 들어 고객 ID, 이름, 전화번호, 이메일 등을 입력하여 고객 정보를 조회하고자 할 때 이 도구를 선택해야 합니다.",
whenNotToUse = "개인 고객이 아닌 법인 고객 정보 조회, 익명 또는 비개인 정보 요청, 또는 개인 정보 보호 정책상 정보 조회가 제한된 경우 이 도구를 선택하지 않아야 합니다.",
ioLimits = "입력 필드는 고객 ID(필수) 및 선택적 이름, 전화번호, 이메일을 허용하며, 최대 10개의 필드를 포함할 수 있습니다. 출력 필드는 결과 코드, 고객 ID, 이름, 전화번호, 이메일, 주소, 생년월일, 멤버십 등급 등을 포함하며, 최대 10개의 필드를 반환할 수 있습니다.",
displayDescription = "개인 고객의 상세 정보를 조회하는 포털용 간략한 설명으로, 이름, 연락처, 주소, 생년월일, 멤버십 등급 등을 확인할 수 있습니다.",
exampleQueries = {"고객 ID 12345로 개인 고객 정보를 조회해줘", "김철수 고객의 상세 정보를 알려줘", "010-1234-5678로 등록된 개인 고객 정보를 조회해줘", "lee@example.com 이메일로 가입된 개인 고객 정보를 조회해줘"},
destructive = false,
idempotent = true,
tags = {"고객관리", "조회"},
ownerOrg = "MCP_TOOL"
)
PersonalCustomerDetailInquiryResponse execute(PersonalCustomerDetailInquiryRequest req);
}

View File

@@ -0,0 +1,69 @@
package io.shinhanlife.dat.mcc.biz.pro.usecase.impl;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse;
import io.shinhanlife.dat.mcc.biz.pro.usecase.PersonalCustomerDetailInquiryUseCase;
import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.glow.communication.dto.Transfer;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import io.shinhanlife.dat.mcc.biz.pro.converter.PersonalCustomerDetailInquiryConverter;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_I;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io. DATNCSO00001_O;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.MciNcscClient;
/**
* @package io.shinhanlife.dat.mcc.biz.pro.usecase.impl
* @className PersonalCustomerDetailInquiryUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author jade
* @create 2026.08.25
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.08.25 jade 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PersonalCustomerDetailInquiryUseCaseImpl implements PersonalCustomerDetailInquiryUseCase {
private final MciNcscClient mci;
private final PersonalCustomerDetailInquiryConverter converter;
@Override
public PersonalCustomerDetailInquiryResponse execute(PersonalCustomerDetailInquiryRequest req) {
log.info("[MCI Tool] {} 요청 수신.", "pro_personal_inquiry");
try {
// MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO)
DATNCSO00001_I mciReq = converter.toLegacyRequest(req);
Transfer< DATNCSO00001_O> resTransfer = mci.callTo(
" DATNCSO00001",
null,
mciReq,
DATNCSO00001_O.class
);
PersonalCustomerDetailInquiryResponse response = new PersonalCustomerDetailInquiryResponse();
if (resTransfer.getBody() != null) {
response = converter.toResponse(resTransfer.getBody());
}
response.setResultCode("SUCCESS");
response.setResultMessage(resTransfer.getBody() != null
? "MCI call completed."
: "MCI call completed without a response body.");
return response;
} catch (Exception e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
PersonalCustomerDetailInquiryResponse response = new PersonalCustomerDetailInquiryResponse();
response.setResultCode("ERROR");
response.setResultMessage(e.getMessage() != null ? e.getMessage() : "Unknown error");
return response;
}
}
}

View File

@@ -0,0 +1,30 @@
package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import io.shinhanlife.dat.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.glow.communication.dto.Transfer;
/**
* @package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c
* @className MciNcscClient
* @description AX HUB 시스템 처리 클래스
* @author jade
* @create 2026.08.25
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.08.25 jade 최초생성
*
* </pre>
*/
@Component
@RequiredArgsConstructor
public class MciNcscClient {
private final AxhubMciComponent mci;
public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) throws Exception {
return mci.callTo(interfaceId, dummy, mciReq, resType);
}
}

View File

@@ -0,0 +1,24 @@
package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.Pattern;
@Data
public class DATNCSO00001_I {
@Schema(description = "조회할 고객의 고유 ID (형식: ^[A-Za-z0-9-]{1,20}$) (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED)
@Pattern(regexp = "^[A-Za-z0-9-]{1,20}$")
private String customerId;
@Schema(description = "고객 이름 (선택적 확인용) (형식: ^[가-힣a-zA-Z\s]{2,20}$) (예시: 김철수, Lee Chul-soo)", example = "김철수")
@Pattern(regexp = "^[가-힣a-zA-Z\s]{2,20}$")
private String name;
@Schema(description = "고객 전화번호 (선택적 확인용) (형식: {4}$) (예시: 010-1234-5678, +821012345678)", example = "010-1234-5678")
private String phoneNumber;
@Schema(description = "고객 이메일 주소 (선택적 확인용) (형식: $) (예시: customer@example.com, user@test.co.kr)", example = "customer@example.com")
private String email;
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dat.mcc.infra.itrf.mci.ncs.c.io;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class DATNCSO00001_O {
@Schema(description = "조회 결과 상태 코드 (예시: SUCCESS, ERROR_001)", example = "SUCCESS", requiredMode = Schema.RequiredMode.REQUIRED)
private String resultCode;
@Schema(description = "조회된 고객의 고유 ID (예시: 12345, CUST-00123)", example = "12345", requiredMode = Schema.RequiredMode.REQUIRED)
private String customerId;
@Schema(description = "고객의 전체 이름 (예시: 김철수, Lee Chul-soo)", example = "김철수", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@Schema(description = "고객의 전화번호 (예시: 010-1234-5678)", example = "010-1234-5678", requiredMode = Schema.RequiredMode.REQUIRED)
private String phoneNumber;
@Schema(description = "고객의 이메일 주소 (예시: customer@example.com)", example = "customer@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
private String email;
@Schema(description = "고객의 주소 정보 (예시: 서울시 강남구 테헤란로 123)", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "고객의 생년월일 (예시: 1990-01-15)", example = "1990-01-15")
private String birthDate;
@Schema(description = "고객의 멤버십 등급 (예시: GOLD, SILVER, BRONZE)", example = "GOLD")
private String membershipLevel;
}

View File

@@ -0,0 +1,10 @@
{
"resultCode" : "SUCCESS",
"customerId" : "12345",
"name" : "김철수",
"phoneNumber" : "010-1234-5678",
"email" : "customer@example.com",
"address" : "서울시 강남구 테헤란로 123",
"birthDate" : "1990-01-15",
"membershipLevel" : "GOLD"
}

View File

@@ -0,0 +1,16 @@
package io.shinhanlife.dat.mcc.biz.pro.usecase;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryRequest;
import io.shinhanlife.dat.mcc.biz.pro.dto.PersonalCustomerDetailInquiryResponse;
import org.junit.jupiter.api.Test;
class PersonalCustomerDetailInquiryUseCaseTest {
@Test
void createsToolRequestAndResponseDtos() {
assertNotNull(new PersonalCustomerDetailInquiryRequest());
assertNotNull(new PersonalCustomerDetailInquiryResponse());
}
}

View File

@@ -4,6 +4,7 @@ import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchRequest;
import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchResponse;
import io.shinhanlife.dat.mcc.biz.cmm.usecase.ClaimSearchUseCase;
import io.shinhanlife.glow.communication.dto.Transfer;
import io.shinhanlife.glow.BizException;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -57,7 +58,7 @@ public class ClaimSearchUseCaseImpl implements ClaimSearchUseCase {
? "MCI call completed."
: "MCI call completed without a response body.");
return response;
} catch (Exception e) {
} catch (BizException e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
ClaimSearchResponse response = new ClaimSearchResponse();
response.setResultCode("ERROR");

View File

@@ -24,7 +24,7 @@ import io.shinhanlife.glow.communication.dto.Transfer;
public class MciNclaClient {
private final AxhubMciComponent mci;
public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) throws Exception {
public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) {
return mci.callTo(interfaceId, dummy, mciReq, resType);
}
}

View File

@@ -0,0 +1,47 @@
package io.shinhanlife.dat.mcc.biz.cmm.usecase.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.shinhanlife.dat.mcc.biz.cmm.converter.ClaimSearchConverter;
import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchRequest;
import io.shinhanlife.dat.mcc.biz.cmm.dto.ClaimSearchResponse;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncla.MciNclaClient;
import io.shinhanlife.dat.mcc.infra.itrf.mci.ncla.io.CLCNNB00001_O;
import io.shinhanlife.glow.BizException;
import org.junit.jupiter.api.Test;
class ClaimSearchUseCaseImplTest {
@Test
void returnsErrorResponseWhenMciReportsABusinessCommunicationFailure() {
MciNclaClient mci = mock(MciNclaClient.class);
when(mci.callTo(eq("CLCNNB00001"), any(), any(), eq(CLCNNB00001_O.class)))
.thenThrow(bizException());
ClaimSearchUseCaseImpl useCase = new ClaimSearchUseCaseImpl(mci, mock(ClaimSearchConverter.class));
ClaimSearchResponse response = useCase.searchClaim(mock(ClaimSearchRequest.class));
assertEquals("ERROR", response.getResultCode());
}
@Test
void doesNotHideUnexpectedRuntimeFailures() {
MciNclaClient mci = mock(MciNclaClient.class);
when(mci.callTo(eq("CLCNNB00001"), any(), any(), eq(CLCNNB00001_O.class)))
.thenThrow(new IllegalStateException("unexpected defect"));
ClaimSearchUseCaseImpl useCase = new ClaimSearchUseCaseImpl(mci, mock(ClaimSearchConverter.class));
assertThrows(IllegalStateException.class,
() -> useCase.searchClaim(mock(ClaimSearchRequest.class)));
}
private BizException bizException() {
return new BizException("CST00477", new String[]{"MCI communication failure"},
new RuntimeException("connection closed"));
}
}

View File

@@ -0,0 +1,73 @@
# MCP Header Contract Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the legacy Tool Pod request headers with the approved 12-header contract and propagate every value through execution and outbound HTTP calls.
**Architecture:** `McpRequestHeaders` is the normalized immutable carrier. Both MCP filtering and the legacy REST adapter populate it, `McpToolExecutionService` returns the transport-safe correlation headers, and `AxhubHttpComponent` forwards the complete contract to downstream HTTP services. All headers remain optional.
**Tech Stack:** Java 21, Spring Web, Jakarta Servlet, JUnit 5, AssertJ, Gradle
**Spec:** User-approved header list in the 2026-08-25 Codex task.
## Global Constraints
- Canonical headers are exactly `X-Guid`, `X-Praf-No`, `X-Request-Id`, `X-Request-Time`, `X-Vrtl-Praf-No`, `X-App-Code`, `X-Project-Code`, `X-User-Ip`, `X-Caller-Ip`, `X-Caller-Host`, `X-Channel`, and `X-Agent-Id`.
- Remove legacy `guid`, `employee-no`, and `virtual-employee-no`; do not retain aliases.
- Keep `mcp-session-id` because it is MCP transport metadata rather than a replaced business header.
- Do not reject absent headers; capture and propagate values only when present.
- Do not commit or push unless the user requests it separately.
---
### Task 1: Request Capture Contract
**Files:**
- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/mcp/McpRequestHeaderFilterTest.java`
- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/BusinessToolControllerHeaderContractTest.java`
- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaders.java`
- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpRequestHeaderFilter.java`
- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/presentation/BusinessToolController.java`
**Interfaces:**
- Produces: `McpRequestHeaders` accessors for all 12 canonical values plus `mcpSessionId()`.
- [ ] Change the filter test fixture to send literal canonical headers and assert every record component.
- [ ] Change the controller contract test to assert the exact 12 canonical names plus `mcp-session-id`, all with `required=false`.
- [ ] Run the two tests and confirm failure because the record and controller still expose legacy fields.
- [ ] Expand the record and populate it from both HTTP entry points.
- [ ] Run the two tests and confirm they pass.
### Task 2: Execution Response and Downstream Propagation
**Files:**
- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolExecutionServiceTest.java`
- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponentTest.java`
- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/McpToolExecutionService.java`
- Modify: `dat-was-lib/src/main/java/io/shinhanlife/dat/lib/integration/http/component/AxhubHttpComponent.java`
**Interfaces:**
- Consumes: the expanded `McpRequestHeaders` record from Task 1.
- Produces: response headers `X-Request-Id`, `X-Guid`, and `mcp-session-id`; downstream calls receive all 12 canonical headers plus the MCP session header.
- [ ] Update execution and HTTP integration tests with hand-written literal values for every field.
- [ ] Run both tests and confirm failure on missing canonical propagation.
- [ ] Return canonical correlation headers and forward all present request headers.
- [ ] Run both tests and confirm they pass.
### Task 3: Documentation and Console Contract
**Files:**
- Modify: `README.md`
- Modify: `dat-was-lib/src/main/resources/static/tool-test-console.html`
- Modify: `dat-was-lib/src/test/java/io/shinhanlife/dat/mcc/presentation/ToolTestConsoleResourceTest.java`
**Interfaces:**
- Consumes: the canonical header names from Tasks 1 and 2.
- Produces: runnable examples and console calls that no longer send removed headers.
- [ ] Update the console resource test to require canonical header labels and calls.
- [ ] Run the resource test and confirm failure on legacy header text.
- [ ] Update README examples/table and console request/response labels.
- [ ] Run the resource test and confirm it passes.
- [ ] Run `./gradlew.bat :dat-was-lib:test` and confirm the full module suite passes.