diff --git a/README.md b/README.md
index b2d6654..131f697 100644
--- a/README.md
+++ b/README.md
@@ -80,19 +80,24 @@ Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정
## Correlation과 로그
-호출자가 보내는 헤더는 다섯 개이며 **모두 선택값**이다. 값은 그대로 Tool Service 요청 헤더로 bypass한다.
+Agent Builder가 보내는 표준 헤더는 12개다. MCP는 권한 판정을 하지 않지만, 필수값·형식·요청 시각 오차는 HTTP 입력 경계에서 검증한다.
-| 헤더 | 의미 | 없을 때 |
+| 헤더 | 형식/조건 | MCP 동작 |
|---|---|---|
-| `guid` | 요청 하나를 끝까지 따라가는 상관 값(UUID) | 서버가 생성 |
-| `x-request-id` | 개별 HTTP 요청 ID | 서버가 생성 |
-| `mcp-session-id` | initialize lifecycle 상관 값 | 전달하지 않음 |
-| `employee-no` | 암호화된 사원번호 | 전달하지 않음 |
-| `virtual-employee-no` | 암호화된 가상사원번호(상담사 등 비사원) | 전달하지 않음 |
+| `X-Guid` | UUID V4 | end-to-end 상관 값으로 응답과 Tool Service 호출에 이어서 사용 |
+| `X-Praf-No` | 숫자 8자리. `BATCH`는 공란 허용 | Tool Service로 전달. 권한 판단은 하지 않음 |
+| `X-Request-Id` | UUID V4 | Agent→MCP 요청 식별자. Tool Service 호출 시에는 새 UUID V4로 재채번 |
+| `X-Request-Time` | ISO-8601 offset date-time, 서버 시각 ±5분 | Agent→MCP 요청 시각. Tool Service 호출 시에는 재채번 시각으로 교체 |
+| `X-Vrtl-Praf-No` | `X-Praf-No`가 `0986`으로 시작하면 숫자 8자리 필수, 그 외에는 헤더 제거 | 조건만 검증하고 Tool Service로 전달 |
+| `X-App-Code` | 대문자 3자리 | Tool Service로 전달 |
+| `X-Project-Code` | 대문자 5자리 | Tool Service로 전달 |
+| `X-User-Ip` | 사용자 단말 IP. `BATCH`는 공란 허용 | Tool Service로 전달 |
+| `X-Caller-IP` | 호출 서버 IP | Tool Service 호출 시 MCP 서버 IP로 교체 |
+| `X-Caller-Host` | 호출 서버 host name | Tool Service 호출 시 MCP 서버 host로 교체 |
+| `X-Channel` | `SCRN`, `BATCH`, `MSGR`, `MCP`, `EAI` | Tool Service 호출 시 `MCP`로 교체 |
+| `X-Agent-Id` | API 호출이면 생략 가능 | 있으면 Tool Service로 전달 |
-`employee-no`와 `virtual-employee-no`는 **MCP가 복호화하지 않는 불투명 값**이다. 형식이나 의미를 해석하지 않고, 개행이 섞여 downstream 헤더가 조작되는 것만 막은 뒤 그대로 전달한다.
-
-MDC는 사용하지 않는다. 로그에는 `guid`와 `x-request-id`만 남기며 **사원 식별자는 암호문이라도 기록하지 않는다.** request/response body와 credential도 남기지 않는다.
+MCP는 사번·가상사번·사용자 IP를 로그에 남기지 않는다. MDC는 사용하지 않으며 로그에는 `X-Guid`와 MCP가 받은 `X-Request-Id`만 남긴다. request/response body와 credential도 기본 로그에 남기지 않는다.
`Authorization`은 `mcp.tool-client.forward-authorization` 설정이 켜진 경우에만 전달한다. MCP는 이 값을 해석하지 않는다.
@@ -162,3 +167,4 @@ deployments:
| [codex-workflow.md](docs/codex-workflow.md) | 저장소 작업 규칙과 공개 정책 |
Superseded/Rejected 문서는 이력일 뿐 현재 구현 근거가 아니다. 코드나 공개 계약을 변경할 때는 가까운 테스트와 해당 현재 계약을 함께 수정한다. 변경을 마치기 전에 실행할 검증 명령은 [AGENTS.md](AGENTS.md)의 완료 기준이 정본이다.
+
diff --git a/build.gradle b/build.gradle
index dc62c3b..f6805d2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -69,7 +69,7 @@ tasks.named('check') {
dependsOn 'ideaFormatCheck'
}
-group = 'io.shinhanlife.dap.biz.mcp'
+group = 'io.shinhanlife.dat.biz.mcp'
version = '0.1.0'
java {
diff --git a/config/local-toolserver-info-sample-v1.json b/config/local-toolserver-info-sample-v1.json
index 5f843fa..88fcd7c 100644
--- a/config/local-toolserver-info-sample-v1.json
+++ b/config/local-toolserver-info-sample-v1.json
@@ -13,6 +13,7 @@
}
]
},
+
{
"routeKey": "sal",
"toolServices": [
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java b/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java
deleted file mode 100644
index 88625a9..0000000
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContext.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package io.shinhanlife.dap.biz.mcp.context;
-
-import java.time.Duration;
-import java.time.Instant;
-
-/**
- * 하나의 MCP HTTP 요청 전체에서 공유할 correlation·호출자·deadline 정보를 담는 불변 context입니다. {@link McpRequestContextFactory}가 만들고 HTTP transport, method handler, Tool client,
- * observability 계층이 사용하며 서버 대화 상태를 저장하지 않습니다. {@code guid}는 요청 하나를 끝까지 따라가는 상관 값이고 {@code requestId}는 개별 HTTP 요청 식별자입니다. {@code employeeNo}와
- * {@code virtualEmployeeNo}는 호출자가 암호화해 보낸
- * 불투명 값입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다.
- */
-public record McpRequestContext(
- String routeKey,
- String requestId,
- String guid,
- String mcpSessionId,
- String employeeNo,
- String virtualEmployeeNo,
- String authorization,
- Instant deadline) {
-
- /**
- * deadline이 없는 context를 허용하되 이미 만료된 것으로 취급합니다.
- *
- *
정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우므로 null이 올 수 없습니다. 그래도 null을 현재 시각으로 바꾸는
- * 이유는, 만약 잘못 만들어진 context가 흘러들어오면 {@link #remainingMillis()}가 0 이하가 되어 Tool 호출이 즉시 중단되기 때문입니다. 시간 제한 없이 무한정 호출되는 것보다 안전한 쪽으로 실패합니다.
- */
- public McpRequestContext {
- deadline = deadline == null ? Instant.now() : deadline;
- }
-
- /**
- * 이 요청에 남은 시간을 밀리초로 알려 줍니다.
- *
- *
Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. 이미
- * 시간이 다 됐으면 0 이하가 되고, 그때는 Tool을 호출하지 않고 timeout으로 끝냅니다.
- *
- *
이 예산은 Agent Builder가 연결을 끊는 시각보다 짧아야 합니다. 같거나 길면 MCP가 응답을 만들어도 받을 상대가 이미 사라진 뒤입니다.
- */
- public long remainingMillis() {
- return Duration.between(Instant.now(), deadline).toMillis();
- }
-}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java b/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java
deleted file mode 100644
index 5b57667..0000000
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRequestContextFactory.java
+++ /dev/null
@@ -1,203 +0,0 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
-
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import jakarta.servlet.http.HttpServletRequest;
-import java.time.Instant;
-import java.util.UUID;
-import java.util.regex.Pattern;
-import org.springframework.stereotype.Component;
-import org.springframework.util.StringUtils;
-
-/**
- * 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다.
- * filter의 가장 앞 단계에서 호출되며 {@code guid}, {@code x-request-id}, {@code mcp-session-id}, 사원 식별자, deadline을 정리합니다.
- * 사원 식별자는 해석하지 않고 주입 위험 문자만 차단하며, 주요 의존성은 timeout 설정을 제공하는 {@link McpProperties}입니다.
- */
-@Component
-public class McpRequestContextFactory {
-
- private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
- private static final Pattern SAFE_ROUTE_KEY = Pattern.compile("[A-Za-z0-9._-]{1,64}");
-
- /**
- * 암호문 Base64가 사용할 수 있는 {@code +/=}를 포함합니다.
- * 공백과 제어 문자만 차단해 header 주입을 막고, 값의 의미는 MCP가 해석하지 않습니다.
- */
- private static final Pattern SAFE_OPAQUE_TOKEN = Pattern.compile("[\\x21-\\x7E]{1,2048}");
-
- private final McpProperties properties;
-
- /**
- * 요청 context 생성에 필요한 MCP 설정을 주입받습니다.
- * 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path와 timeout을 사용합니다.
- */
- public McpRequestContextFactory(McpProperties properties) {
- this.properties = properties;
- }
-
- /**
- * HTTP 요청에서 route key와 correlation 헤더를 추출해 불변 context로 만듭니다.
- * 누락 가능한 헤더는 기본값 또는 {@code null}로 정리하고, 잘못된 route나 header 값은 JSON-RPC invalid request 예외로 거부합니다.
- */
- public McpRequestContext extract(HttpServletRequest request) {
- String routeKey = routeKey(request);
- String authorization = trimToNull(request.getHeader("Authorization"));
- String requestId = validatedRequestIdOrGenerated(request.getHeader("x-request-id"));
- String guid = validatedGuidOrGenerated(request.getHeader("guid"));
- String sessionId = validatedOptional(request.getHeader("mcp-session-id"), "mcp-session-id");
- String employeeNo = opaqueOptional(request.getHeader("employee-no"), "employee-no");
- String virtualEmployeeNo =
- opaqueOptional(request.getHeader("virtual-employee-no"), "virtual-employee-no");
-
- return new McpRequestContext(
- routeKey,
- requestId,
- guid,
- sessionId,
- employeeNo,
- virtualEmployeeNo,
- authorization,
- Instant.now().plusMillis(properties.toolClient().requestDeadlineMillis()));
- }
-
- /**
- * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다.
- * Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다.
- */
- private String routeKey(HttpServletRequest request) {
- String path = request.getRequestURI();
- String contextPath = request.getContextPath();
- if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
- path = path.substring(contextPath.length());
- }
- String basePath = properties.endpointPath();
- if (path.equals(basePath)) {
- return defaultRouteKey();
- }
- String prefix = basePath.endsWith("/") ? basePath : basePath + "/";
- if (!path.startsWith(prefix)) {
- return defaultRouteKey();
- }
- if (configuredEndpointRouteKey() != null) {
- throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not allowed for fixed endpoint path");
- }
- String route = path.substring(prefix.length());
- if (route.contains("/") || !SAFE_ROUTE_KEY.matcher(route).matches()) {
- throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is invalid");
- }
- return route;
- }
-
- /**
- * route가 생략된 요청의 처리 방식을 결정합니다.
- * Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다.
- */
- private String defaultRouteKey() {
- String configuredRoute = configuredEndpointRouteKey();
- if (configuredRoute != null) {
- return configuredRoute;
- }
- if (properties.portal() != null && properties.portal().enabled()) {
- throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is required");
- }
- return "";
- }
-
- /**
- * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다.
- * {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다.
- */
- private String configuredEndpointRouteKey() {
- String basePath = properties.endpointPath();
- String prefix = "/mcp/";
- if (basePath != null && basePath.startsWith(prefix)) {
- String route = basePath.substring(prefix.length());
- if (!route.contains("/") && SAFE_ROUTE_KEY.matcher(route).matches()) {
- return route;
- }
- }
- return null;
- }
-
- /**
- * {@code x-request-id}가 있으면 안전성을 검증하고 없으면 {@code req-UUID} 형식으로 새 값을 만듭니다.
- * 값은 개별 HTTP 요청을 구분하며, 잘못된 문자가 있으면 downstream 전파 전에 거부합니다.
- */
- private String validatedRequestIdOrGenerated(String value) {
- String normalized = trimToNull(value);
- if (normalized == null) {
- return "req-" + UUID.randomUUID();
- }
- validate(normalized, "x-request-id");
- return normalized;
- }
-
- /**
- * {@code guid}가 없으면 표준 UUID를 만들고, 있으면 canonical UUID인지 확인합니다.
- * Agent Builder가 보낸 값은 변경하지 않고 응답과 Tool Service 호출에 그대로 사용합니다.
- */
- private String validatedGuidOrGenerated(String value) {
- if (value == null || value.isEmpty()) {
- return UUID.randomUUID().toString();
- }
- try {
- if (!UUID.fromString(value).toString().equalsIgnoreCase(value)) {
- throw new IllegalArgumentException("non-canonical UUID");
- }
- return value;
- } catch (IllegalArgumentException exception) {
- throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "guid must be a UUID");
- }
- }
-
- /**
- * MCP가 해석하지 않는 선택 header 값을 단일 line printable token으로 제한합니다.
- * 공백이나 제어 문자가 있으면 header injection 위험으로 보고 invalid request 예외를 발생시킵니다.
- */
- private String opaqueOptional(String value, String header) {
- if (value == null || value.isEmpty()) {
- return null;
- }
- if (!SAFE_OPAQUE_TOKEN.matcher(value).matches()) {
- throw new JsonRpcException(
- JsonRpcErrorCode.INVALID_REQUEST,
- header + " must be a single-line token of at most 2048 printable characters");
- }
- return value;
- }
-
- /**
- * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다.
- * 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다.
- */
- private String validatedOptional(String value, String header) {
- String normalized = trimToNull(value);
- if (normalized != null) {
- validate(normalized, header);
- }
- return normalized;
- }
-
- /**
- * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다.
- * 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다.
- */
- private void validate(String value, String header) {
- if (!SAFE_CORRELATION_ID.matcher(value).matches()) {
- throw new JsonRpcException(
- JsonRpcErrorCode.INVALID_REQUEST,
- header + " must contain 1-128 safe correlation characters");
- }
- }
-
- /**
- * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다.
- * Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다.
- */
- private String trimToNull(String value) {
- return StringUtils.hasText(value) ? value.trim() : null;
- }
-}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java
similarity index 98%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java
index e86762a..b58938c 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/McpServerApplication.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp;
+package io.shinhanlife.dat.biz.mcp;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java
similarity index 98%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java
index d02bf97..20f8eff 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/config/HttpClientConfig.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.config;
+package io.shinhanlife.dat.biz.mcp.config;
import java.net.http.HttpClient;
import java.time.Duration;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java
similarity index 99%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java
index 1b2c894..9892f46 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.config;
+package io.shinhanlife.dat.biz.mcp.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContext.java b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContext.java
new file mode 100644
index 0000000..894f93b
--- /dev/null
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContext.java
@@ -0,0 +1,45 @@
+package io.shinhanlife.dat.biz.mcp.context;
+
+import io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory;
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * 하나의 MCP HTTP 요청 전체에서 공유할 표준 헤더, correlation, route, deadline 정보를 담는 불변 context입니다.
+ * {@link McpRequestContextFactory}가 Agent Builder의 MCP HTTP 요청에서 만들고 HTTP transport, method handler, Tool client, observability 계층이 사용합니다.
+ * 서버 대화 상태를 저장하지 않으며, 권한 판정은 수행하지 않고 표준 헤더 값을 Tool Service 호출 경계까지 전달하는 역할만 합니다.
+ */
+public record McpRequestContext(
+ String routeKey,
+ String guid,
+ String prafNo,
+ String requestId,
+ String requestTime,
+ String virtualPrafNo,
+ String appCode,
+ String projectCode,
+ String userIp,
+ String callerIp,
+ String callerHost,
+ String channel,
+ String agentId,
+ String mcpSessionId,
+ String authorization,
+ Instant deadline) {
+
+ /**
+ * deadline이 없는 context를 허용하되 이미 만료된 것으로 취급합니다.
+ * 정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우며, 잘못 만들어진 context가 흘러들어오면 Tool 호출이 즉시 timeout으로 실패하도록 보수적으로 정규화합니다.
+ */
+ public McpRequestContext {
+ deadline = deadline == null ? Instant.now() : deadline;
+ }
+
+ /**
+ * 이 요청에 남은 시간을 밀리초로 알려 줍니다.
+ * Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다.
+ */
+ public long remainingMillis() {
+ return Duration.between(Instant.now(), deadline).toMillis();
+ }
+}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java
similarity index 97%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java
index a87ddeb..b619689 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/context/McpRequestContextHolder.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.context;
+package io.shinhanlife.dat.biz.mcp.context;
import java.util.Optional;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java
similarity index 96%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java
index 649ed9d..f01c698 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidator.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java
@@ -1,11 +1,11 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import java.util.Map;
import org.springframework.stereotype.Component;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java
similarity index 91%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java
index 5143d0d..4310dd9 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolCall.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
import com.fasterxml.jackson.databind.JsonNode;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java
similarity index 91%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java
index 7446af2..472bbb8 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionService.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java
@@ -1,16 +1,16 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
import com.fasterxml.jackson.databind.JsonNode;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolResponse;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -159,7 +159,7 @@ public class ToolExecutionService {
}
/**
- * Tool Service가 반환한 정규화된 본문과 MCP가 측정한 실행 시간을 handler에 전달하는 불변 결과입니다. {@link io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler}가 이를 MCP text content와
+ * Tool Service가 반환한 정규화된 본문과 MCP가 측정한 실행 시간을 handler에 전달하는 불변 결과입니다. {@link io.shinhanlife.dat.biz.mcp.method.ToolsCallHandler}가 이를 MCP text content와
* {@code searchTime}으로 변환합니다.
*/
public record Result(JsonNode data, double durationMillis) {
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java
similarity index 87%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java
index 857efa7..35a6dc9 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingService.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest;
import java.net.URI;
import java.util.regex.Pattern;
@@ -13,7 +13,7 @@ import org.springframework.stereotype.Service;
/**
* Registry에서 확정된 Tool metadata를 실제 {@link ToolClient} 호출용 HTTP 요청으로 변환하는 routing 서비스입니다. 확정된 Tool metadata에 대해서만 동작하며, AgentBuilder 대신 Tool을 선택하거나 업무 규칙을 판단하지
- * 않습니다. 주요 의존성은 {@link ToolCall}, {@link ToolMetadata}와 {@link io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest} 계약입니다.
+ * 않습니다. 주요 의존성은 {@link ToolCall}, {@link ToolMetadata}와 {@link io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest} 계약입니다.
*/
@Service
public class ToolRoutingService {
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java
similarity index 97%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java
index c9e258d..6e052da 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcErrorCode.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import io.modelcontextprotocol.spec.McpSchema;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java
similarity index 98%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java
index e20decb..68c5850 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcException.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import com.fasterxml.jackson.databind.JsonNode;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java
similarity index 96%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java
index 8ae7a56..71500fa 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcNotification.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.modelcontextprotocol.spec.McpSchema;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java
similarity index 93%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java
index 64df65a..4f92279 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequest.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import com.fasterxml.jackson.databind.JsonNode;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java
similarity index 98%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java
index a411a45..9ad7a78 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParser.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java
similarity index 95%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java
index a5c7525..8954cd7 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcResponse.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java
@@ -1,9 +1,9 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
import java.util.LinkedHashMap;
import java.util.Map;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java
similarity index 90%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java
index 21b3838..1ba29d4 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandler.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import org.springframework.stereotype.Component;
/**
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java
similarity index 84%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java
index a02619d..0181807 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandler.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java
@@ -1,9 +1,9 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import java.util.Map;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java
similarity index 87%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java
index 5e30d41..3be21b6 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/McpMethodHandlerRegistry.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import java.util.HashMap;
import java.util.List;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java
similarity index 91%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java
index ce057d6..69472da 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandler.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java
@@ -1,14 +1,14 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
import com.fasterxml.jackson.databind.JsonNode;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.execute.ToolCall;
-import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.execute.ToolCall;
+import io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java
similarity index 90%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java
index 0226514..d3e58b4 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandler.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java
@@ -1,20 +1,20 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. 내부 Tool Registry의 활성 metadata를 읽어 MCP SDK의 표준 {@link McpSchema.Tool}과
- * {@link McpSchema.ListToolsResult}로 변환합니다. 주요 의존성은 캐시 및 원천 조회를 감싸는 {@link io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService}이며, Jackson mapper는 local
+ * {@link McpSchema.ListToolsResult}로 변환합니다. 주요 의존성은 캐시 및 원천 조회를 감싸는 {@link io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService}이며, Jackson mapper는 local
* catalog의 공개 필드만 SDK 모델로 옮깁니다. endpoint·timeout 등 실행용 운영 정보는 응답에 노출하지 않습니다.
*/
@Component
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java
similarity index 90%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java
index 9fcfbfc..ca27bf9 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpoint.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
import java.util.List;
import java.util.Map;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java
similarity index 92%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java
index 481e05d..f98f9e6 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryRefreshScheduler;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java
similarity index 94%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java
index 9ebca71..4d75658 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/TraceLogger.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java
@@ -1,8 +1,8 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
import java.util.StringJoiner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java
similarity index 96%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java
index 4831561..369c4eb 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClient.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java
@@ -1,11 +1,11 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java
similarity index 85%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java
index 251bd18..cac92d7 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java
@@ -1,12 +1,12 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleResult;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties.Bundle;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleResult;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
@@ -157,13 +157,16 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
JsonNode routes = registry.path("routes");
if (!routes.isArray()) {
String routeKey = normalizeRouteKey(required(registry, "routeKey"));
- bundlesByRoute.put(routeKey, toBundles(registry.path("toolServices")));
+ bundlesByRoute.put(routeKey, toBundles(routeKey, registry.path("toolServices")).orElseThrow(() -> unavailable("Portal registry has no usable route: " + routeKey)));
return changed;
}
Map> updated = new LinkedHashMap<>();
for (JsonNode route : routes) {
String routeKey = normalizeRouteKey(required(route, "routeKey"));
- updated.put(routeKey, toBundles(route.path("toolServices")));
+ toBundles(routeKey, route.path("toolServices")).ifPresent(bundles -> updated.put(routeKey, bundles));
+ }
+ if (updated.isEmpty()) {
+ throw unavailable("Portal registry has no usable route");
}
bundlesByRoute.keySet().removeIf(routeKey -> !updated.containsKey(routeKey));
bundlesByRoute.putAll(updated);
@@ -282,40 +285,62 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
/**
* 포털의 active Tool Service 목록을 기존 ToolBundleDiscovery가 이해하는 bundle 선언으로 변환합니다.
* 포털 registry는 Tool Server의 domain과 manifest 위치만 제공하므로 개별 Tool endpoint 목록은 읽지 않습니다. Tool 실행 endpoint는 이후
- * Tool Server manifest의 {@code _meta.endpoint}에서 확정하며, active 서비스가 없으면 갱신을 거부합니다.
+ * Tool Server manifest의 {@code _meta.endpoint}에서 확정합니다. 필수 endpoint 정보가 빠진 서비스는 해당 논리 bundle만 제외하고,
+ * 사용할 수 있는 active 서비스가 하나도 없을 때만 갱신을 거부합니다.
*/
- private List toBundles(JsonNode services) {
+ private Optional> toBundles(String routeKey, JsonNode services) {
List bundles = new ArrayList<>();
for (JsonNode service : services) {
if (!"ACTIVE".equalsIgnoreCase(service.path("status").asText("ACTIVE"))) {
continue;
}
+ toBundle(routeKey, service).ifPresent(bundles::add);
+ }
+ if (bundles.isEmpty()) {
+ log.warn("Portal route ignored because it has no usable active Tool Service. routeKey={}", routeKey);
+ return Optional.empty();
+ }
+ return Optional.of(List.copyOf(bundles));
+ }
+
+ /**
+ * 포털의 Tool Service 한 건을 manifest discovery용 bundle로 변환합니다.
+ * serviceKey, serviceDomain, manifestPath 중 하나라도 없으면 그 서비스만 사용할 수 없는 항목으로 보고 제외하며, 나머지 정상 서비스의
+ * manifest 조회와 snapshot 갱신은 계속 진행되게 합니다.
+ */
+ private Optional toBundle(String routeKey, JsonNode service) {
+ try {
String serviceDomain = trimTrailingSlash(required(service, "serviceDomain"));
String manifestPath = normalizePath(required(service, "manifestPath"));
- bundles.add(new Bundle(
+ return Optional.of(new Bundle(
required(service, "serviceKey"),
serviceDomain + manifestPath,
serviceDomain,
service.path("namePrefix").asText(""),
true,
null));
+ } catch (JsonRpcException exception) {
+ log.warn("Portal Tool Service ignored because required endpoint metadata is missing. routeKey={}, reason={}",
+ routeKey,
+ exception.getMessage());
+ return Optional.empty();
}
- if (bundles.isEmpty()) {
- throw unavailable("Portal registry has no active Tool Service");
- }
- return List.copyOf(bundles);
}
/**
- * Tool Service별 discovery 결과를 하나의 MCP Tool catalog로 병합합니다.
- * 사용 가능한 성공본이 없는 서비스, Tool name 중복, 전체 상한 초과는 불완전한 snapshot을 만들지 않도록 실패 처리합니다.
+ * Tool Service별 discovery 결과 중 사용 가능한 snapshot만 하나의 MCP Tool catalog로 병합합니다.
+ * 일부 Tool Service가 아직 한 번도 성공하지 못했거나 일시 장애여도 다른 성공 bundle의 Tool은 유지하며, 모든 bundle이 사용할 수 없을 때만 route 갱신을 실패 처리합니다.
+ * Tool name 중복과 전체 상한 초과는 성공 bundle 사이에서도 불완전한 snapshot을 만들 수 있으므로 그대로 실패 처리합니다.
*/
private List merge(List results) {
- if (results.stream().anyMatch(result -> !result.usableSnapshot())) {
- throw unavailable("At least one Portal Tool Service has no usable snapshot");
+ List usableResults = results.stream()
+ .filter(BundleResult::usableSnapshot)
+ .toList();
+ if (usableResults.isEmpty()) {
+ throw unavailable("No Portal Tool Service has a usable snapshot");
}
List candidates = new ArrayList<>();
- for (BundleResult result : results) {
+ for (BundleResult result : usableResults) {
result.tools().forEach(tool -> candidates.add(new BundleTool(result.bundleId(), tool)));
}
candidates.sort(Comparator.comparing(BundleTool::bundleId).thenComparing(entry -> entry.tool().name()));
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java
similarity index 84%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java
index 53de90e..c1af76c 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisPortalRegistryCache.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java
@@ -1,12 +1,14 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@@ -47,9 +49,12 @@ public class RedisPortalRegistryCache {
return Optional.empty();
}
return Optional.of(objectMapper.readTree(json));
- } catch (Exception exception) {
+ } catch (DataAccessException exception) {
log.warn("Redis Portal registry cache read failed: {}", exception.getClass().getSimpleName());
return Optional.empty();
+ } catch (JsonProcessingException exception) {
+ log.warn("Redis Portal registry cache JSON parse failed: {}", exception.getClass().getSimpleName());
+ return Optional.empty();
}
}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java
similarity index 90%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java
index 0215579..b3a364a 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCache.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java
@@ -1,8 +1,9 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
@@ -11,6 +12,7 @@ import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@@ -86,9 +88,12 @@ public class RedisToolRegistryCache {
}
return Optional.of(objectMapper.readValue(json, new TypeReference<>() {
}));
- } catch (Exception exception) {
+ } catch (DataAccessException exception) {
logFailure("read", routeKey, exception);
return Optional.empty();
+ } catch (JsonProcessingException exception) {
+ logFailure("deserialize", routeKey, exception);
+ return Optional.empty();
}
}
@@ -107,8 +112,10 @@ public class RedisToolRegistryCache {
public void saveSnapshot(String routeKey, List tools) {
try {
redisTemplate.opsForValue().set(key(routeKey), objectMapper.writeValueAsString(tools), ttl);
- } catch (Exception exception) {
+ } catch (DataAccessException exception) {
logFailure("write", routeKey, exception);
+ } catch (JsonProcessingException exception) {
+ logFailure("serialize", routeKey, exception);
}
}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java
similarity index 99%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java
index 60addc3..ea2f45b 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscovery.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties.Bundle;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java
similarity index 72%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java
index ceb116e..ea622a9 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryClient.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java
@@ -1,9 +1,9 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleResult;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleResult;
import java.util.ArrayList;
import java.util.Comparator;
@@ -35,26 +35,31 @@ public class ToolBundleRegistryClient implements ToolRegistryClient {
}
/**
- * 활성 bundle을 모두 조회한 뒤 검증을 통과한 Tool을 병합해 반환합니다. 각 bundle이 이번 조회 결과 또는 직전 성공본을 가져야 전체 snapshot을 확정합니다. 하나라도 사용 가능한 성공본이 없으면 Registry unavailable을 던져,
+ * 활성 bundle을 모두 조회한 뒤 사용할 수 있는 Tool snapshot만 병합해 반환합니다.
+ * 일부 bundle이 아직 한 번도 성공하지 못했어도 다른 bundle의 논리 MCP 동작을 막지 않으며, 모든 bundle이 사용할 수 없을 때만 Registry unavailable을 던져
* {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다.
*/
@Override
public List fetchTools(String routeKey) {
List results = discovery.discoverAll();
- if (results.stream().anyMatch(result -> !result.usableSnapshot())) {
- throw new JsonRpcException(
- JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE,
- "At least one Tool bundle has no usable snapshot");
- }
return merge(results);
}
/**
- * bundle별 Tool을 이름 충돌과 총량 상한을 확인하며 하나의 정렬된 목록으로 합칩니다. 이름이 겹치거나 총량 상한을 넘으면 불완전한 목록을 만들지 않고 전체 갱신을 거부합니다.
+ * 사용 가능한 bundle의 Tool을 이름 충돌과 총량 상한을 확인하며 하나의 정렬된 목록으로 합칩니다.
+ * 전부 사용할 수 없으면 실패시키고, 일부만 사용할 수 없으면 성공 bundle만 반영해 논리 MCP 간 장애 전파를 막습니다.
*/
private List merge(List results) {
+ List usableResults = results.stream()
+ .filter(BundleResult::usableSnapshot)
+ .toList();
+ if (usableResults.isEmpty()) {
+ throw new JsonRpcException(
+ JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE,
+ "No Tool bundle has a usable snapshot");
+ }
List candidates = new ArrayList<>();
- for (BundleResult result : results) {
+ for (BundleResult result : usableResults) {
result.tools().forEach(tool -> candidates.add(new BundleTool(result.bundleId(), tool)));
}
candidates.sort(
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java
similarity index 91%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java
index 26fa990..1d37c19 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolListChangedEvent.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java
@@ -1,6 +1,6 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcNotification;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcNotification;
/**
* route별 Tool catalog snapshot이 실제로 변경됐음을 transport 계층에 전달하는 내부 도메인 이벤트입니다.
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java
similarity index 97%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java
index 9f2db20..3959739 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.JsonNode;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java
similarity index 98%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java
index 31b0fc0..d5bbc5c 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import java.util.List;
import java.util.Map;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java
similarity index 99%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java
index ae1a55b..5877754 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import java.util.concurrent.TimeUnit;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java
similarity index 61%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java
index 402ce33..ceee42c 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.transport.http.McpRouteKeyValidator;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.transport.http.McpRouteKeyValidator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -21,7 +21,9 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
/**
- * Tool Registry metadata 議고쉶???⑥씪 吏꾩엯?먯씠硫??붿껌 寃쎈줈? 諛곌꼍 媛깆떊 寃쎈줈瑜?遺꾨━?섎뒗 ?쒕퉬?ㅼ엯?덈떎. {@code tools/list}? {@code tools/call}???붿껌 寃쎈줈??in-memory snapshot留??쎌쑝誘濡?Redis ?μ븷??吏?곗씠 ?묐떟?? * ?곹뼢??二쇱? ?딆뒿?덈떎. Redis??諛곌꼍 媛깆떊怨?warm start?먯꽌留??ъ슜?섎뒗 replica 媛?怨듭쑀 吏?먯씠硫? ?먯쿇 議고쉶 ?깃났 寃곌낵留???ν빀?덈떎. 二쇱슂 ?섏〈?깆? ?먯쿇 port {@link ToolRegistryClient}? ?좏깮??Redis cache?낅땲??
+ * Tool Registry metadata 조회를 담당하는 서비스입니다. {@code tools/list}와 {@code tools/call} 요청 경로는
+ * route별 in-memory snapshot만 읽고, 포털/Tool Service/Redis 조회는 기동 직후 또는 배경 갱신 경로에서만 수행합니다.
+ * 주요 협력 객체는 원천 조회 port인 {@link ToolRegistryClient}, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자입니다.
*/
@Service
public class ToolRegistryService implements McpRouteKeyValidator {
@@ -37,7 +39,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
new ConcurrentHashMap<>();
/**
- * ?먯쿇 Registry? memory쨌?좏깮??Redis 怨듭쑀 cache瑜?二쇱엯諛쏆뒿?덈떎.
+ * 원천 Registry와 선택적 Redis 공유 cache를 주입받습니다.
+ * 테스트에서 주로 사용하며 이벤트 발행자와 JSON mapper는 기본값으로 구성합니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient, Optional redisCache) {
@@ -46,8 +49,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?먯쿇 Registry, ?좏깮??Redis 怨듭쑀 cache, Tool 紐⑸줉 蹂寃??대깽??諛쒗뻾?먮? 二쇱엯諛쏆뒿?덈떎.
- * Spring 湲곕룞 ???몄텧?섎ʼn, refresh ?깃났?쇰줈 湲곗〈 route snapshot???щ씪吏??뚮쭔 ?대깽?몃? 諛쒗뻾?⑸땲??
+ * 원천 Registry, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자를 주입받습니다.
+ * Spring 기동 시 호출되며, refresh 성공 후 기존 route snapshot과 달라진 경우에만 변경 이벤트를 발행합니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient,
@@ -57,8 +60,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?먯쿇 Registry, ?좏깮??Redis cache, 蹂寃??대깽??諛쒗뻾?? JSON 吏곷젹???꾧뎄瑜?二쇱엯諛쏆뒿?덈떎.
- * Spring 湲곕룞 ???몄텧?섎ʼn snapshot 蹂寃?寃利?濡쒓렇瑜?JSON ?뺥깭濡??④만 ???덇쾶 ObjectMapper瑜?蹂닿??⑸땲??
+ * 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, 검증 로그용 JSON 직렬화 도구를 주입받습니다.
+ * 운영 코드에서 사용하는 생성자이며 route별 snapshot 갱신과 Redis fallback 정책을 이 서비스 안에 모읍니다.
*/
@Autowired
public ToolRegistryService(
@@ -73,15 +76,16 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?쒖꽦 Tool 紐⑸줉??in-memory snapshot?먯꽌 ?쎌뒿?덈떎. ?붿껌 寃쎈줈?먯꽌??Redis瑜??몄텧?섏? ?딆쑝誘濡?Redis ?μ븷??吏?곗씠 {@code tools/list} ?묐떟 ?쒓컙???곹뼢??二쇱? ?딆뒿?덈떎. snapshot???꾩쭅 鍮꾩뼱 ?덈뒗 湲곕룞 吏곹썑?먮쭔 ?먯쿇????踰? * 議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
+ * 기본 route의 활성 Tool 목록을 in-memory snapshot에서 읽습니다.
+ * 요청 경로에서는 Redis를 호출하지 않으며, snapshot이 아직 없을 때만 원천 조회로 cold start 공백을 메웁니다.
*/
public List listTools() {
return listTools("");
}
/**
- * route蹂?in-memory snapshot?먯꽌 ?쒖꽦 Tool 紐⑸줉???쎌뒿?덈떎.
- * ?붿껌 route??snapshot???놁쑝硫??대떦 route??Registry ?먯쿇????踰?議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
+ * 지정 route의 활성 Tool 목록을 in-memory snapshot에서 읽습니다.
+ * 해당 route snapshot이 없으면 그 route에 대해서만 Registry 원천을 한 번 조회해 memory에 적재합니다.
*/
public List listTools(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -93,14 +97,16 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?붿껌??泥섎━?????덈뒗 Tool snapshot??memory???곸옱?먮뒗吏 諛섑솚?⑸땲?? ?먯쿇 ?먮뒗 Redis?먯꽌 ?깃났?곸쑝濡?梨꾪깮??鍮?紐⑸줉???좏슚???꾩껜 ?곹깭?대?濡?{@code null} ?щ?留??먮떒?섎ʼn, readiness ?뺤씤 怨쇱젙?먯꽌 Redis??Tool Service瑜? * ?몄텧?섏? ?딆뒿?덈떎.
+ * 요청 처리에 사용할 수 있는 route별 Tool snapshot이 memory에 하나라도 있는지 반환합니다.
+ * readiness 확인 과정에서 원격 Tool Service나 Redis를 직접 호출하지 않기 위한 상태 판단용 메서드입니다.
*/
public boolean hasUsableSnapshot() {
return !snapshotsByRoute.isEmpty();
}
/**
- * 湲곕룞 吏곹썑 ?ㅻⅨ replica媛 怨듭쑀 吏?먯뿉 ??ν빐 ??snapshot??癒쇱? ?곸옱?⑸땲?? 泥??먯쿇 議고쉶媛 ?앸굹湲??꾩쓽 鍮?紐⑸줉 援ш컙??以꾩씠湲??꾪븳 best-effort ?숈옉?대ʼn, ?ㅽ뙣?섍굅??媛믪씠 ?놁쑝硫??꾨Т寃껊룄 ?섏? ?딆뒿?덈떎.
+ * 기동 직후 다른 replica가 Redis에 저장한 기본 route snapshot을 best-effort로 적재합니다.
+ * 이미 memory snapshot이 있거나 Redis 값이 없으면 아무 작업도 하지 않으며, 요청 경로의 정확성을 Redis에 의존하지 않습니다.
*/
public void warmStartFromSharedCache() {
if (!snapshotsByRoute.isEmpty()) {
@@ -112,15 +118,16 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?쒖? Tool ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎. cache媛 ?ㅻ옒?먯쓣 ???덉쑝誘濡?泥?議고쉶?먯꽌 紐?李얠쑝硫?Registry瑜???踰?refresh????理쒖쥌 ?먮떒?⑸땲??
+ * 기본 route snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다.
+ * 첫 조회에서 찾지 못하면 stale snapshot 가능성을 고려해 원천을 한 번 refresh한 뒤 최종 판단합니다.
*/
public ToolMetadata findEnabledTool(String name) {
return findEnabledTool("", name);
}
/**
- * ?붿껌 route??Tool snapshot?먯꽌 ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎.
- * route蹂?cache媛 ?ㅻ옒?섏뿀?????덉쑝誘濡?理쒖큹 miss ???대떦 route留?refresh????理쒖쥌 ?먮떒?⑸땲??
+ * 지정 route snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다.
+ * route별 cache가 오래되었을 수 있으므로 최초 miss 시 해당 route만 refresh한 뒤 Tool 없음 여부를 확정합니다.
*/
public ToolMetadata findEnabledTool(String routeKey, String name) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -144,15 +151,16 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * Registry ?먯쿇??吏곸젒 ?쎌뼱 ?쒖꽦 Tool snapshot??媛깆떊?⑸땲?? 議고쉶???깃났?덉쓣 ?뚮쭔 snapshot??援먯껜?섍퀬 怨듭쑀 cache????ν븯誘濡? ?ㅽ뙣媛 湲곗〈 紐⑸줉??鍮꾩슦嫄곕굹 ?ㅻⅨ replica媛 ??ν븳 ?뺤긽 snapshot????뼱?곗? ?딆뒿?덈떎. memory瑜? * 癒쇱? 媛깆떊??Redis ?μ븷? 臾닿??섍쾶 理쒖떊 ?곹깭瑜??좎??⑸땲?? ?먯쿇 議고쉶媛 ?ㅽ뙣?섎㈃ 湲곗〈 memory瑜??좎??섍퀬, memory媛 鍮꾩뼱 ?덉쓣 ?뚮쭔 怨듭쑀 cache瑜?梨꾪깮?⑸땲??
+ * 기본 route의 Registry 원천을 직접 조회해 활성 Tool snapshot을 갱신합니다.
+ * 조회가 성공한 경우에만 memory와 Redis를 갱신하고, 실패 시 기존 memory를 우선 유지합니다.
*/
public List refresh() {
return refresh("");
}
/**
- * 吏?뺥븳 route??Registry ?먯쿇??吏곸젒 ?쎌뼱 route蹂?snapshot??媛깆떊?⑸땲??
- * 媛숈? route???숈떆 refresh??single-flight濡?臾띔퀬, ?ㅻⅨ route???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲??
+ * 지정 route의 Registry 원천을 직접 조회해 route별 snapshot을 갱신합니다.
+ * 같은 route에 동시에 들어온 refresh는 single-flight로 묶고, 서로 다른 route는 독립적으로 갱신합니다.
*/
public List refresh(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -175,8 +183,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?꾩옱 memory???뚮젮吏?紐⑤뱺 route瑜?二쇨린?곸쑝濡?媛깆떊?⑸땲??
- * ?꾩쭅 route ?붿껌???놁쑝硫?湲곗〈 湲곕낯 route留?媛깆떊??湲곗〈 ?⑥씪 route ?숈옉???좎??⑸땲??
+ * 현재 memory에 알려진 모든 route를 주기적으로 갱신합니다.
+ * Registry client가 전체 route snapshot을 제공하면 한 번에 반영하고, 그렇지 않으면 기존 route별 refresh를 수행합니다.
*/
public void refreshKnownRoutes() {
Map> snapshots = registryClient.fetchAllTools();
@@ -192,8 +200,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?ы꽭泥섎읆 蹂꾨룄 registry瑜?媛吏??먯쿇??endpoint 紐⑸줉留?媛깆떊?⑸땲??
- * Tool manifest 議고쉶? memory snapshot 援먯껜???섑뻾?섏? ?딆쑝硫? scheduler媛 ?ы꽭 ?꾩슜 二쇨린?먯꽌 ?몄텧?⑸땲??
+ * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다.
+ * Tool manifest 조회와 memory snapshot 교체는 여기서 수행하지 않고 scheduler의 별도 주기에서 처리합니다.
*/
public boolean refreshSourceRegistry() {
return registryClient.refreshSourceRegistry();
@@ -209,7 +217,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * Tool ?먯쿇????踰?議고쉶?섍퀬 ?깃났???꾩껜 snapshot留?memory? Redis??諛섏쁺?⑸땲?? ?먯쿇 ?ㅽ뙣 ??湲곗〈 memory瑜?理쒖슦?좎쑝濡??좎??섍퀬, memory媛 鍮꾩뼱 ?덉쓣 ?뚮쭔 Redis last-good??梨꾪깮?⑸땲??
+ * Tool 원천을 한 번 조회하고 성공한 활성 snapshot만 memory와 Redis에 반영합니다.
+ * 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 Redis last-good snapshot을 fallback으로 채택합니다.
*/
private List refreshOnce(String routeKey) {
try {
@@ -237,11 +246,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?ㅻⅨ ?몄텧???쒖옉??refresh 寃곌낵瑜?湲곕떎由щʼn ?먮옒 RuntimeException ?좏삎??蹂댁〈?⑸땲?? ?щ윭 cache miss媛 ?숈떆??諛쒖깮?대룄 紐⑤뱺 ?몄텧?먭? 媛숈? source fetch 寃곌낵瑜??ъ슜?⑸땲??
- */
- /**
- * ?꾩껜 registry snapshot 議고쉶 寃곌낵瑜?route蹂?memory snapshot??諛섏쁺?⑸땲??
- * ?먯쿇 議고쉶媛 ?대? ?깃났??紐⑸줉留??ㅼ뼱?ㅻ?濡??붿껌 寃쎈줈? Redis 寃쎈줈瑜?嫄대뱶由ъ? ?딄퀬, 湲곗〈 snapshot怨?鍮꾧탳??濡쒓렇? 蹂寃??대깽?몃쭔 泥섎━?⑸땲??
+ * 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다.
+ * 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로나 Redis 경로를 다시 건드리지 않고, 기존 snapshot과 비교해 로그와 변경 이벤트만 처리합니다.
*/
private void replaceSnapshot(String routeKey, List tools) {
List immutableTools = List.copyOf(tools);
@@ -251,6 +257,10 @@ public class ToolRegistryService implements McpRouteKeyValidator {
redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools));
}
+ /**
+ * 이미 시작된 같은 route의 refresh 결과를 기다리고 원래 RuntimeException 유형을 보존해 전달합니다.
+ * 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 재사용하게 합니다.
+ */
private List awaitRefresh(CompletableFuture> refresh) {
try {
return refresh.join();
@@ -263,7 +273,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * ?대쫫 議곌굔?쇰줈 ?쒖꽦 Tool ?꾨낫瑜?李얠뒿?덈떎. ?대쫫 以묐났? ?먯쿇 snapshot 蹂묓빀 ?④퀎?먯꽌 嫄곕??⑸땲??
+ * 이름 조건으로 활성 Tool metadata를 찾습니다.
+ * 이름 중복은 원천 snapshot 병합 단계에서 거절되므로 여기서는 첫 일치 항목만 반환합니다.
*/
private Optional match(List tools, String name) {
return tools.stream()
@@ -273,7 +284,7 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * 李얠? 紐삵븳 Tool ?대쫫???ы븿??Tool not found ?덉쇅瑜?留뚮벊?덈떎.
+ * 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다.
*/
private JsonRpcException notFound(String name) {
return new JsonRpcException(
@@ -281,8 +292,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * 湲곗〈 snapshot??議댁옱?섍퀬 ??snapshot怨??ㅻ? ?뚮쭔 Tool 紐⑸줉 蹂寃??대깽?몃? 諛쒗뻾?⑸땲??
- * 理쒖큹 濡쒕뵫? Agent Builder媛 ?꾩쭅 紐⑸줉??諛쏄린 ?꾩씪 ???덉쑝誘濡??뚮┝ ??곸뿉???쒖쇅?섍퀬, ?ㅼ젣 援먯껜媛 諛쒖깮??refresh?먮쭔 ?곹뼢??以띾땲??
+ * 기존 snapshot이 존재하고 새 snapshot과 다를 때만 Tool 목록 변경 이벤트를 발행합니다.
+ * 최초 로딩은 Agent Builder가 아직 목록을 받기 전일 수 있으므로 알림 대상에서 제외하고, 실제 교체가 발생한 refresh에만 영향을 줍니다.
*/
private void publishListChangedIfNeeded(
String routeKey, List previous, List current) {
@@ -292,8 +303,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * 濡쒖뺄 寃利앹쓣 ?꾪빐 route蹂?in-memory snapshot??理쒖큹 ?깅줉?섍굅???ㅼ젣 蹂寃쎈맆 ?뚮쭔 INFO 濡쒓렇濡??④퉩?덈떎.
- * Portal ?먮뒗 Tool Service revision 蹂寃쎌씠 memory??諛섏쁺?섏뿀?붿? ?뺤씤?????덈룄濡?Tool metadata ?꾩껜瑜?湲곕줉?⑸땲??
+ * 로컬 검증을 위해 route별 in-memory snapshot이 최초 등록되거나 실제 변경될 때만 INFO 로그로 남깁니다.
+ * 포털 또는 Tool Service revision 변경이 memory에 반영되었는지 확인할 수 있도록 Tool metadata 전체를 기록합니다.
*/
private void logSnapshot(String routeKey, List previous, List current) {
boolean changed = previous == null || !previous.equals(current);
@@ -306,8 +317,8 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * 寃利?濡쒓렇???ъ슜??route蹂?snapshot ?댁슜??JSON 臾몄옄?대줈 蹂?섑빀?덈떎.
- * 吏곷젹???ㅽ뙣媛 refresh ?깃났 ?щ????곹뼢??二쇱? ?딅룄濡??ㅽ뙣 ??理쒖냼 臾몄옄???쒗쁽?쇰줈 ?泥댄빀?덈떎.
+ * 검증 로그에 사용할 route별 snapshot 내용을 JSON 문자열로 변환합니다.
+ * 직렬화 실패가 refresh 성공 여부에 영향을 주지 않도록 실패 시 최소 문자열 표현으로 대체합니다.
*/
private String snapshotJson(String routeKey, List current) {
Map body = new LinkedHashMap<>();
@@ -323,7 +334,7 @@ public class ToolRegistryService implements McpRouteKeyValidator {
}
/**
- * route key??null怨?怨듬갚??湲곗〈 ?⑥씪 snapshot key??鍮?臾몄옄?대줈 ?뺢퇋?뷀빀?덈떎.
+ * route key의 null과 공백을 기존 기본 route key인 빈 문자열로 정규화합니다.
*/
private String normalizeRouteKey(String routeKey) {
return routeKey == null ? "" : routeKey.trim();
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java
similarity index 66%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java
index 7ca07b8..f9f8987 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClient.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java
@@ -1,16 +1,22 @@
-package io.shinhanlife.dap.biz.mcp.toolclient;
+package io.shinhanlife.dat.biz.mcp.toolclient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TextNode;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolResponse;
+import io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory;
+import java.net.InetAddress;
import java.net.SocketTimeoutException;
import java.net.http.HttpClient;
import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -23,8 +29,9 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
/**
- * Tool Service로 HTTP 요청을 보내고 일반 JSON·text 응답을 내부 계약으로 정규화하는 outbound client입니다. Registry 기반 {@code tools/call} 실행이 이 구현을 사용하며, 요청 context의 correlation·사원 식별자
- * 헤더를 그대로 bypass하고 선택적 Authorization과 request deadline을 함께 전달합니다. 주요 의존성은 RestClient, 공유 JDK HttpClient, McpProperties, ObjectMapper입니다.
+ * Tool Service로 HTTP 요청을 보내고 일반 JSON·text 응답을 내부 계약으로 정규화하는 outbound client입니다.
+ * Registry 기반 {@code tools/call} 실행이 이 구현을 사용하며, Agent Builder에서 들어온 표준 추적 헤더를 Tool Service 계약에 맞게 재구성합니다.
+ * Tool Service 호출마다 {@code X-Request-Id}와 {@code X-Request-Time}을 새로 만들고, 주요 의존성은 RestClient, 공유 JDK HttpClient, McpProperties, ObjectMapper입니다.
*/
@Component
public class HttpToolClient implements ToolClient {
@@ -37,6 +44,7 @@ public class HttpToolClient implements ToolClient {
/**
* JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다.
+ * 생성 시점에는 외부 호출을 하지 않고, {@link #execute(ToolRequest, McpRequestContext)}에서 요청별 timeout과 표준 헤더를 조합합니다.
*/
public HttpToolClient(
ObjectMapper objectMapper,
@@ -48,7 +56,8 @@ public class HttpToolClient implements ToolClient {
}
/**
- * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. correlation 헤더를 전달하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다.
+ * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다.
+ * Tool 호출용 request ID를 새로 발급하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다.
*/
@Override
public ToolResponse execute(ToolRequest request, McpRequestContext context) {
@@ -100,27 +109,38 @@ public class HttpToolClient implements ToolClient {
return String.valueOf(value);
}
}
+
/**
- * URI, bypass 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. Agent Builder가 보낸 correlation·사원 식별자는 이름과 값을 바꾸지 않고 그대로 실어 보냅니다. 사원 식별자는 암호문이며 MCP는 복호화하지 않으므로, 이
- * 경계에서는 전달만 하고 해석하지 않습니다.
+ * URI, 표준 추적 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다.
+ * end-to-end 값인 {@code X-Guid}는 이어가고, Tool Service 요청의 단건 식별자인 {@code X-Request-Id}와 {@code X-Request-Time}은 여기서 새로 발급합니다.
*/
private RestClient.RequestBodySpec requestSpec(ToolRequest request, McpRequestContext context) {
RestClient client = clientFor(remainingTimeoutMillis(request, context));
+ String toolRequestId = UUID.randomUUID().toString();
+ String toolRequestTime = OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
return client
.post()
.uri(request.endpoint())
.headers(
headers -> {
- set(headers, "x-request-id", context.requestId());
- set(headers, "guid", context.guid());
- set(headers, "employee-no", context.employeeNo());
- set(headers, "virtual-employee-no", context.virtualEmployeeNo());
+ set(headers, McpRequestContextFactory.HEADER_GUID, context.guid());
+ set(headers, McpRequestContextFactory.HEADER_PRAF_NO, context.prafNo());
+ set(headers, McpRequestContextFactory.HEADER_REQUEST_ID, toolRequestId);
+ set(headers, McpRequestContextFactory.HEADER_REQUEST_TIME, toolRequestTime);
+ set(headers, McpRequestContextFactory.HEADER_VRTL_PRAF_NO, context.virtualPrafNo());
+ set(headers, McpRequestContextFactory.HEADER_APP_CODE, context.appCode());
+ set(headers, McpRequestContextFactory.HEADER_PROJECT_CODE, context.projectCode());
+ set(headers, McpRequestContextFactory.HEADER_USER_IP, context.userIp());
+ set(headers, McpRequestContextFactory.HEADER_CALLER_IP, localHostAddress());
+ set(headers, McpRequestContextFactory.HEADER_CALLER_HOST, localHostName());
+ set(headers, McpRequestContextFactory.HEADER_CHANNEL, "MCP");
+ set(headers, McpRequestContextFactory.HEADER_AGENT_ID, context.agentId());
set(headers, "mcp-session-id", context.mcpSessionId());
set(headers, "X-Tool-Server-API-Key", properties.toolClient().apiKey());
if (properties.toolClient().forwardAuthorization()) {
set(headers, "Authorization", context.authorization());
}
- })
+ })
.contentType(MediaType.APPLICATION_JSON)
.body(request.arguments());
}
@@ -150,6 +170,7 @@ public class HttpToolClient implements ToolClient {
/**
* 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다.
+ * BATCH에서 공란으로 들어온 사번·사용자 IP는 빈 문자열 그대로 전달해 호출 성격을 유지합니다.
*/
private void set(org.springframework.http.HttpHeaders headers, String name, String value) {
if (value != null) {
@@ -157,6 +178,30 @@ public class HttpToolClient implements ToolClient {
}
}
+ /**
+ * 현재 MCP 서버의 IP를 Tool Service 호출자 IP 헤더에 넣기 위해 조회합니다.
+ * OS 조회가 실패하면 빈 값을 보내지 않고 보수적으로 {@code unknown}을 사용해 문제 위치가 드러나게 합니다.
+ */
+ private String localHostAddress() {
+ try {
+ return InetAddress.getLocalHost().getHostAddress();
+ } catch (Exception exception) {
+ return "unknown";
+ }
+ }
+
+ /**
+ * 현재 MCP 서버의 host name을 Tool Service 호출자 host 헤더에 넣기 위해 조회합니다.
+ * 컨테이너나 폐쇄망 설정 문제로 조회가 실패하면 {@code unknown}을 사용합니다.
+ */
+ private String localHostName() {
+ try {
+ return InetAddress.getLocalHost().getHostName();
+ } catch (Exception exception) {
+ return "unknown";
+ }
+ }
+
/**
* upstream HTTP 상태를 권한 오류 또는 일반 실행 오류 ToolClientException으로 변환합니다.
*/
@@ -193,7 +238,8 @@ public class HttpToolClient implements ToolClient {
}
/**
- * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다.
+ * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다.
+ * JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다.
*/
private JsonNode parseResponse(String body, MediaType contentType) {
if (body == null) {
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java
similarity index 94%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java
index 9b74628..f47e37e 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/toolclient/ToolClient.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.toolclient;
+package io.shinhanlife.dat.biz.mcp.toolclient;
import com.fasterxml.jackson.databind.JsonNode;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
/**
- * 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해
+ * 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해
* 반환합니다.
*/
public interface ToolClient {
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java
similarity index 99%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java
index 2ddb89d..1dfabfb 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/CachedBodyHttpServletRequest.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java
similarity index 87%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java
index a636d7d..bc6cd1e 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpController.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java
@@ -1,15 +1,16 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
-import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequestParser;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.method.McpMethodHandlerRegistry;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -96,7 +97,7 @@ public class McpController {
private String toJson(Object value) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
- } catch (Exception exception) {
+ } catch (JsonProcessingException exception) {
return String.valueOf(value);
}
}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java
similarity index 93%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java
index 6fdd450..527c75a 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandler.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java
@@ -1,9 +1,9 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
-import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import java.util.Set;
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java
similarity index 71%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java
index bb71716..d4ef5f9 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilter.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java
@@ -1,17 +1,21 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
-import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
-import io.shinhanlife.dap.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
+import io.shinhanlife.dat.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
+import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@@ -22,17 +26,19 @@ import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
-import org.springframework.web.filter.OncePerRequestFilter;
/**
- * 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 필터입니다. Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다.
- * payload, credential, 사원 식별자는 로그에 저장하지 않습니다. 주요 의존성은 endpoint 설정, header 추출기, JSON mapper, protocol validator와 {@link TraceLogger}입니다.
+ * 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 표준 Servlet 필터입니다.
+ * Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다.
+ * Spring {@code OncePerRequestFilter} 초기화 경로를 사용하지 않기 위해 {@link Filter}를 직접 구현하며, 중복 실행 방어는 request attribute로 수행합니다.
+ * 주요 의존성은 endpoint 설정, header 추출기, JSON mapper, protocol validator와 {@link TraceLogger}입니다.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
-public class McpExchangeFilter extends OncePerRequestFilter {
+public class McpExchangeFilter implements Filter {
private static final Logger log = LoggerFactory.getLogger(McpExchangeFilter.class);
+ private static final String FILTER_APPLIED = McpExchangeFilter.class.getName() + ".FILTERED";
private final McpRequestContextFactory headerExtractor;
private final TraceLogger traceLogger;
@@ -42,7 +48,8 @@ public class McpExchangeFilter extends OncePerRequestFilter {
private final McpRouteKeyValidator routeKeyValidator;
/**
- * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. 별도 payload capture나 audit sink는 조립하지 않습니다.
+ * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다.
+ * 생성 시점에는 외부 시스템을 호출하지 않으며, 실제 MCP 요청이 들어왔을 때만 context와 검증 흐름을 시작합니다.
*/
public McpExchangeFilter(
McpRequestContextFactory headerExtractor,
@@ -60,10 +67,38 @@ public class McpExchangeFilter extends OncePerRequestFilter {
}
/**
- * 설정된 MCP endpoint 이외의 다른 MCP·health·management 요청은 correlation 처리와 MCP 로그 대상에서 제외합니다.
+ * Servlet container가 호출하는 표준 필터 진입점입니다.
+ * HTTP가 아니거나 MCP endpoint 대상이 아닌 요청은 그대로 다음 filter로 넘기고, MCP 요청은 중복 실행 방어 후 내부 처리 메서드로 위임합니다.
*/
@Override
- protected boolean shouldNotFilter(HttpServletRequest request) {
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
+ throws IOException, ServletException {
+ if (!(request instanceof HttpServletRequest httpRequest)
+ || !(response instanceof HttpServletResponse httpResponse)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+ if (shouldSkip(httpRequest)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+ if (httpRequest.getAttribute(FILTER_APPLIED) != null) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ httpRequest.setAttribute(FILTER_APPLIED, Boolean.TRUE);
+ try {
+ doMcpFilter(httpRequest, httpResponse, filterChain);
+ } finally {
+ httpRequest.removeAttribute(FILTER_APPLIED);
+ }
+ }
+
+ /**
+ * 설정된 MCP endpoint 이외의 다른 HTTP 요청은 correlation 처리와 MCP 로그 대상에서 제외합니다.
+ */
+ private boolean shouldSkip(HttpServletRequest request) {
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return true;
}
@@ -78,31 +113,17 @@ public class McpExchangeFilter extends OncePerRequestFilter {
/**
* MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다.
- *
- * 처리 순서는 다음과 같습니다.
- *
- *
- * - 헤더에서 correlation 값을 뽑아 context를 만들고 응답 헤더에 먼저 심는다(오류 응답에도 실리도록)
- *
- 본문을 크기 제한과 함께 읽어 다시 읽을 수 있는 wrapper로 감싼다
- *
- 로그·검증용으로 JSON-RPC method만 미리 확인한다
- *
- protocol version을 검증하고, 실패하면 controller까지 가지 않고 HTTP 400으로 끝낸다
- *
- controller 체인을 실행하고 응답 완료 로그를 남긴다
- *
- *
- * 어떤 경로로 끝나든 {@code finally}에서 ThreadLocal을 지웁니다. thread는 다음 요청에 재사용되므로, 지우지 않으면 이전 요청의 사용자
- * 정보가 섞입니다.
+ * 헤더 검증, route 검증, request body 재사용 wrapper, protocol version 검증을 Controller 진입 전에 수행합니다.
*/
- @Override
- protected void doFilterInternal(
- HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ private void doMcpFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
long startedNanos = System.nanoTime();
try {
McpRequestContext context = headerExtractor.extract(request);
validateKnownRoute(context, request);
McpRequestContextHolder.set(context);
- response.setHeader("guid", context.guid());
- response.setHeader("x-request-id", context.requestId());
+ response.setHeader(McpRequestContextFactory.HEADER_GUID, context.guid());
+ response.setHeader(McpRequestContextFactory.HEADER_REQUEST_ID, context.requestId());
CachedBodyHttpServletRequest cachedRequest =
new CachedBodyHttpServletRequest(request, properties.trace().maxBodyBytes());
@@ -159,9 +180,7 @@ public class McpExchangeFilter extends OncePerRequestFilter {
writeJsonRpcError(response, exception.errorCode(), exception.errorData());
} catch (IOException exception) {
// 여기까지 온 IOException은 대개 "쓰려는데 상대가 이미 끊었다"(broken pipe)다.
- // Agent Builder는 응답을 5분 이상 기다리지 않으므로, 오래 걸린 Tool 결과가 이 경로로 버려진다.
- // 조용히 사라지면 나중에 추적이 불가능하므로 guid와 함께 별도 event로 남긴다.
- // Tool은 이미 실행됐을 수 있다. 재시도 중복 실행 방지는 Tool Service 몫이며 guid 재사용 규칙은 별도 합의 대상이다.
+ // Tool은 이미 실행됐을 수 있으므로, 재시도 중복 실행 방지는 Tool Service 책임이다.
traceLogger.error(
"mcp_http_response_undeliverable",
exception,
@@ -200,33 +219,27 @@ public class McpExchangeFilter extends OncePerRequestFilter {
return path.equals(basePath) && basePath != null && basePath.startsWith("/mcp/");
}
- /**
- * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다.
- *
- *
여기서 읽어도 controller가 같은 본문을 다시 읽을 수 있습니다. {@link CachedBodyHttpServletRequest}가 호출할 때마다 새
- * 스트림을 만들어 주기 때문입니다.
- *
- *
JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려줍니다. 이 단계는 관찰이 목적이고, 잘못된 JSON을 어떤 오류로 응답할지는
- * 뒤쪽 request adapter가 정하기 때문입니다. 여기서 먼저 실패시키면 오류 계약이 두 곳으로 갈라집니다.
- *
- * @return method 이름. 읽을 수 없으면 {@code null}
- */
/**
* 임시 요청 본문 로그를 보기 쉽게 출력하기 위해 JSON이면 개행된 문자열로 변환합니다.
- * JSON 파싱이 실패하면 원문 문자열을 그대로 돌려 요청 처리 흐름에는 영향을 주지 않습니다.
+ * JSON 파싱이나 직렬화가 실패하면 원문 문자열을 그대로 돌려 요청 처리 흐름에는 영향을 주지 않습니다.
*/
private String prettyJson(String body) {
try {
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectMapper.readTree(body));
- } catch (Exception exception) {
+ } catch (JsonProcessingException exception) {
return body;
}
}
+
+ /**
+ * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다.
+ * JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려주며, 실제 오류 계약은 뒤쪽 request adapter가 결정합니다.
+ */
private String extractMethod(CachedBodyHttpServletRequest request) {
try {
JsonNode envelope = objectMapper.readTree(request.getInputStream());
return envelope == null ? null : envelope.path("method").asText(null);
- } catch (Exception ignored) {
+ } catch (IOException exception) {
return null;
}
}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java
similarity index 95%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java
index 053c3d3..7caa971 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidator.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java
new file mode 100644
index 0000000..3ac6b9f
--- /dev/null
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRequestContextFactory.java
@@ -0,0 +1,361 @@
+package io.shinhanlife.dat.biz.mcp.transport.http;
+
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+/**
+ * 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다.
+ * filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더의 필수 여부와 형식을 검증합니다.
+ * 권한 판정이나 업무 의미 해석은 하지 않고, 검증된 값을 요청 context에 보관해 Tool Service 호출 단계로 넘깁니다.
+ */
+@Component
+public class McpRequestContextFactory {
+
+ public static final String HEADER_GUID = "X-Guid";
+ public static final String HEADER_PRAF_NO = "X-Praf-No";
+ public static final String HEADER_REQUEST_ID = "X-Request-Id";
+ public static final String HEADER_REQUEST_TIME = "X-Request-Time";
+ public static final String HEADER_VRTL_PRAF_NO = "X-Vrtl-Praf-No";
+ public static final String HEADER_APP_CODE = "X-App-Code";
+ public static final String HEADER_PROJECT_CODE = "X-Project-Code";
+ public static final String HEADER_USER_IP = "X-User-Ip";
+ public static final String HEADER_CALLER_IP = "X-Caller-IP";
+ public static final String HEADER_CALLER_HOST = "X-Caller-Host";
+ public static final String HEADER_CHANNEL = "X-Channel";
+ public static final String HEADER_AGENT_ID = "X-Agent-Id";
+
+ private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
+ private static final Pattern SAFE_ROUTE_KEY = Pattern.compile("[A-Za-z0-9._-]{1,64}");
+ private static final Pattern UUID_V4 = Pattern.compile(
+ "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}");
+ private static final Pattern EIGHT_DIGITS = Pattern.compile("\\d{8}");
+ private static final Pattern APP_CODE = Pattern.compile("[A-Z]{3}");
+ private static final Pattern PROJECT_CODE = Pattern.compile("[A-Z]{5}");
+ private static final Pattern CALLER_HOST = Pattern.compile("[A-Za-z0-9._-]{1,255}");
+ private static final Pattern IPV4 = Pattern.compile(
+ "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}");
+ private static final Pattern IPV6_LIKE = Pattern.compile("[0-9A-Fa-f:.%]{2,64}");
+ private static final Pattern SAFE_AGENT_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
+ private static final Set CHANNELS = Set.of("SCRN", "BATCH", "MSGR", "MCP", "EAI");
+ private static final Duration REQUEST_TIME_TOLERANCE = Duration.ofMinutes(5);
+
+ private final McpProperties properties;
+
+ /**
+ * 요청 context 생성에 필요한 MCP 설정을 주입받습니다.
+ * 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path, 표준 헤더, timeout 설정을 사용합니다.
+ */
+ public McpRequestContextFactory(McpProperties properties) {
+ this.properties = properties;
+ }
+
+ /**
+ * HTTP 요청에서 route key와 표준 헤더를 추출해 불변 context로 만듭니다.
+ * 누락·형식 오류·요청 시각 오차는 controller 진입 전에 JSON-RPC invalid request 예외로 거부하고, 권한 관련 판단은 하지 않습니다.
+ */
+ public McpRequestContext extract(HttpServletRequest request) {
+ String routeKey = routeKey(request);
+ String authorization = trimToNull(request.getHeader("Authorization"));
+ String channel = requiredEnum(request, HEADER_CHANNEL, CHANNELS);
+ boolean batch = "BATCH".equals(channel);
+ String guid = requiredUuidV4(request, HEADER_GUID);
+ String requestId = requiredUuidV4(request, HEADER_REQUEST_ID);
+ String requestTime = requiredRequestTime(request);
+ String prafNo = employeeNo(request, HEADER_PRAF_NO, batch);
+ String virtualPrafNo = virtualPrafNo(request, prafNo, batch);
+ String appCode = requiredPattern(request, HEADER_APP_CODE, APP_CODE, "must be 3 uppercase letters");
+ String projectCode = requiredPattern(request, HEADER_PROJECT_CODE, PROJECT_CODE, "must be 5 uppercase letters");
+ String userIp = ipAddress(request, HEADER_USER_IP, batch);
+ String callerIp = ipAddress(request, HEADER_CALLER_IP, false);
+ String callerHost = requiredPattern(request, HEADER_CALLER_HOST, CALLER_HOST, "must be a safe host name");
+ String agentId = optionalPattern(request, HEADER_AGENT_ID, SAFE_AGENT_ID, "must be a safe identifier");
+ String sessionId = validatedOptional(request.getHeader("mcp-session-id"), "mcp-session-id");
+
+ return new McpRequestContext(
+ routeKey,
+ guid,
+ prafNo,
+ requestId,
+ requestTime,
+ virtualPrafNo,
+ appCode,
+ projectCode,
+ userIp,
+ callerIp,
+ callerHost,
+ channel,
+ agentId,
+ sessionId,
+ authorization,
+ Instant.now().plusMillis(properties.toolClient().requestDeadlineMillis()));
+ }
+
+ /**
+ * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다.
+ * Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다.
+ */
+ private String routeKey(HttpServletRequest request) {
+ String path = request.getRequestURI();
+ String contextPath = request.getContextPath();
+ if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
+ path = path.substring(contextPath.length());
+ }
+ String basePath = properties.endpointPath();
+ if (path.equals(basePath)) {
+ return defaultRouteKey();
+ }
+ String prefix = basePath.endsWith("/") ? basePath : basePath + "/";
+ if (!path.startsWith(prefix)) {
+ return defaultRouteKey();
+ }
+ if (configuredEndpointRouteKey() != null) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not allowed for fixed endpoint path");
+ }
+ String route = path.substring(prefix.length());
+ if (route.contains("/") || !SAFE_ROUTE_KEY.matcher(route).matches()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is invalid");
+ }
+ return route;
+ }
+
+ /**
+ * route가 생략된 요청의 처리 방식을 결정합니다.
+ * Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다.
+ */
+ private String defaultRouteKey() {
+ String configuredRoute = configuredEndpointRouteKey();
+ if (configuredRoute != null) {
+ return configuredRoute;
+ }
+ if (properties.portal() != null && properties.portal().enabled()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is required");
+ }
+ return "";
+ }
+
+ /**
+ * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다.
+ * {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다.
+ */
+ private String configuredEndpointRouteKey() {
+ String basePath = properties.endpointPath();
+ String prefix = "/mcp/";
+ if (basePath != null && basePath.startsWith(prefix)) {
+ String route = basePath.substring(prefix.length());
+ if (!route.contains("/") && SAFE_ROUTE_KEY.matcher(route).matches()) {
+ return route;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 필수 UUID V4 헤더를 읽어 canonical 형식인지 검증합니다.
+ * 누락되거나 UUID V4가 아니면 요청 추적 계약 위반으로 보고 invalid request 예외를 발생시킵니다.
+ */
+ private String requiredUuidV4(HttpServletRequest request, String header) {
+ String value = required(request, header);
+ if (!UUID_V4.matcher(value).matches()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be a UUID v4");
+ }
+ try {
+ return UUID.fromString(value).toString();
+ } catch (IllegalArgumentException exception) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be a UUID v4");
+ }
+ }
+
+ /**
+ * {@code X-Request-Time}을 ISO-8601 offset 시각으로 파싱하고 현재 시각과 5분 이상 차이 나는 요청을 거부합니다.
+ * request replay나 오래 지연된 호출이 Tool Service까지 전달되지 않도록 입력 경계에서 차단합니다.
+ */
+ private String requiredRequestTime(HttpServletRequest request) {
+ String value = required(request, HEADER_REQUEST_TIME);
+ try {
+ Instant requestInstant = OffsetDateTime.parse(value).toInstant();
+ Duration skew = Duration.between(requestInstant, Instant.now()).abs();
+ if (skew.compareTo(REQUEST_TIME_TOLERANCE) > 0) {
+ throw new JsonRpcException(
+ JsonRpcErrorCode.INVALID_REQUEST,
+ HEADER_REQUEST_TIME + " must be within 5 minutes of server time");
+ }
+ return value;
+ } catch (DateTimeParseException exception) {
+ throw new JsonRpcException(
+ JsonRpcErrorCode.INVALID_REQUEST,
+ HEADER_REQUEST_TIME + " must be ISO-8601 offset date-time");
+ }
+ }
+
+ /**
+ * 사번 헤더를 검증합니다.
+ * 화면·메신저·MCP·EAI 호출은 숫자 8자리를 요구하고, 사람이 없는 BATCH 호출은 공란 또는 누락을 허용합니다.
+ */
+ private String employeeNo(HttpServletRequest request, String header, boolean batch) {
+ String value = valueOrEmpty(request.getHeader(header));
+ if (batch && value.isEmpty()) {
+ return "";
+ }
+ if (!EIGHT_DIGITS.matcher(value).matches()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be 8 digits");
+ }
+ return value;
+ }
+
+ /**
+ * 협력사 사번의 가상사번 조건을 검증합니다.
+ * {@code 0986}으로 시작하는 사번은 가상사번을 필수로 받고, 그 외 호출에서는 빈 문자열을 포함해 전달된 헤더 값을 거부합니다.
+ */
+ private String virtualPrafNo(HttpServletRequest request, String prafNo, boolean batch) {
+ boolean present = hasHeader(request, HEADER_VRTL_PRAF_NO);
+ String value = valueOrEmpty(request.getHeader(HEADER_VRTL_PRAF_NO));
+ if (!batch && prafNo.startsWith("0986")) {
+ if (!EIGHT_DIGITS.matcher(value).matches()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, HEADER_VRTL_PRAF_NO + " is required for partner employee");
+ }
+ return value;
+ }
+ if (present) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, HEADER_VRTL_PRAF_NO + " must be absent for non-partner employee");
+ }
+ return null;
+ }
+
+ /**
+ * IP 헤더를 검증합니다.
+ * BATCH의 사용자 IP만 공란 또는 누락을 허용하고, 그 외 IP는 IPv4 또는 IPv6 형태의 안전한 값만 통과시킵니다.
+ */
+ private String ipAddress(HttpServletRequest request, String header, boolean blankAllowed) {
+ String value = valueOrEmpty(request.getHeader(header));
+ if (blankAllowed && value.isEmpty()) {
+ return "";
+ }
+ if (!isIpAddress(value)) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be an IP address");
+ }
+ return value;
+ }
+
+ /**
+ * 필수 헤더 값을 지정한 정규식으로 검증합니다.
+ * 값이 없거나 형식이 다르면 Tool 실행 전 단계에서 invalid request 예외를 발생시킵니다.
+ */
+ private String requiredPattern(HttpServletRequest request, String header, Pattern pattern, String message) {
+ String value = required(request, header);
+ if (!pattern.matcher(value).matches()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " " + message);
+ }
+ return value;
+ }
+
+ /**
+ * 선택 헤더가 있을 때만 지정한 정규식으로 검증합니다.
+ * Agent ID처럼 API 호출에서는 빠질 수 있는 값은 누락을 허용하되, 전달된 값의 header injection 위험은 차단합니다.
+ */
+ private String optionalPattern(HttpServletRequest request, String header, Pattern pattern, String message) {
+ String value = trimToNull(request.getHeader(header));
+ if (value == null) {
+ return null;
+ }
+ if (!pattern.matcher(value).matches()) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " " + message);
+ }
+ return value;
+ }
+
+ /**
+ * 필수 enum 헤더가 허용된 코드 집합에 포함되는지 검증합니다.
+ * 현재는 화면·배치·메신저·MCP·EAI 채널만 허용합니다.
+ */
+ private String requiredEnum(HttpServletRequest request, String header, Set allowed) {
+ String value = required(request, header);
+ if (!allowed.contains(value)) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " is invalid");
+ }
+ return value;
+ }
+
+ /**
+ * 필수 헤더를 공백 제거 후 반환합니다.
+ * 누락되거나 공백뿐이면 표준 헤더 계약 위반으로 보고 invalid request 예외를 발생시킵니다.
+ */
+ private String required(HttpServletRequest request, String header) {
+ String value = trimToNull(request.getHeader(header));
+ if (value == null) {
+ throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " is required");
+ }
+ return value;
+ }
+
+ /**
+ * HTTP 요청에 특정 헤더가 실제로 포함되어 있었는지 확인합니다.
+ * 비협력사 호출에서 빈 가상사번 헤더를 보낸 경우도 제거되지 않은 것으로 판단하기 위해 값 조회와 별도로 사용합니다.
+ */
+ private boolean hasHeader(HttpServletRequest request, String header) {
+ return request.getHeaderNames().asIterator().hasNext()
+ && request.getHeaders(header).asIterator().hasNext();
+ }
+
+ /**
+ * IPv4는 octet 범위를 엄격히 확인하고, IPv6는 주소 문자 집합과 콜론 포함 여부를 기준으로 안전한 문자열만 허용합니다.
+ * DNS 조회는 하지 않아 요청 처리 지연이나 외부 의존성을 만들지 않습니다.
+ */
+ private boolean isIpAddress(String value) {
+ if (!StringUtils.hasText(value)) {
+ return false;
+ }
+ return IPV4.matcher(value).matches() || (value.contains(":") && IPV6_LIKE.matcher(value).matches());
+ }
+
+ /**
+ * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다.
+ * 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다.
+ */
+ private String validatedOptional(String value, String header) {
+ String normalized = trimToNull(value);
+ if (normalized != null) {
+ validate(normalized, header);
+ }
+ return normalized;
+ }
+
+ /**
+ * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다.
+ * 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다.
+ */
+ private void validate(String value, String header) {
+ if (!SAFE_CORRELATION_ID.matcher(value).matches()) {
+ throw new JsonRpcException(
+ JsonRpcErrorCode.INVALID_REQUEST,
+ header + " must contain 1-128 safe correlation characters");
+ }
+ }
+
+ /**
+ * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다.
+ * Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다.
+ */
+ private String trimToNull(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
+
+ /**
+ * null은 빈 문자열로 정규화하되, 입력값의 공백은 보존합니다.
+ * 사번과 IP처럼 정확한 형식이 필요한 헤더에서 공백 포함 값을 실수로 허용하지 않기 위해 사용합니다.
+ */
+ private String valueOrEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRouteKeyValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java
similarity index 93%
rename from src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRouteKeyValidator.java
rename to src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java
index 0b50b1c..e9d2a26 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/transport/http/McpRouteKeyValidator.java
+++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
/**
* MCP HTTP 경계에서 추출한 route key가 현재 서버가 처리할 수 있는 route인지 확인하는 transport 전용 port입니다.
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java
deleted file mode 100644
index 3b6dbb6..0000000
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/ArchitectureDocumentContractTest.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package io.shinhanlife.dap.biz.mcp.docs;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-
-import org.junit.jupiter.api.Test;
-
-/**
- * {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네
- * 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
- */
-class ArchitectureDocumentContractTest {
-
- private static final Path ARCHITECTURE = Path.of("docs", "architecture.md");
- private static final Path MAIN_PACKAGE =
- Path.of("src", "main", "java", "io", "shinhanlife", "dap", "biz", "mcp");
- /**
- * 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다.
- */
- private static final Pattern TABLE_ROW =
- Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|");
-
- /**
- * 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다.
- */
- @Test
- void everyDocumentedClassPathStillExists() throws IOException {
- List documented = documentedTypes();
-
- // 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다.
- assertThat(documented)
- .withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.")
- .hasSizeGreaterThan(10);
-
- List missing = documented.stream().filter(type -> !sourceExists(type)).toList();
-
- assertThat(missing)
- .withFailMessage(
- "architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n"
- + "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.",
- missing)
- .isEmpty();
- }
-
- /**
- * 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다.
- */
- private List documentedTypes() throws IOException {
- try (Stream lines = Files.lines(ARCHITECTURE)) {
- return lines.map(TABLE_ROW::matcher)
- .filter(Matcher::find)
- .map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2)))
- .distinct()
- .toList();
- }
- }
-
- /**
- * 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다.
- */
- private boolean sourceExists(DocumentedType type) {
- return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java"));
- }
-
- private record DocumentedType(String name, String packagePath) {
- }
-}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java
deleted file mode 100644
index 63e4d7f..0000000
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/CodeStyleContractTest.java
+++ /dev/null
@@ -1,215 +0,0 @@
-package io.shinhanlife.dap.biz.mcp.docs;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.Predicate;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
-
-import org.junit.jupiter.api.Test;
-
-/**
- * Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을
- * 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다.
- *
- * 여기서 보는 것은 도구 없이도 판정할 수 있는 규칙뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며
- * 애플리케이션 context를 띄우지 않습니다.
- */
-class CodeStyleContractTest {
-
- private static final List SOURCE_ROOTS =
- List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java"));
- /**
- * {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다.
- */
- private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);");
-
- /**
- * 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다.
- */
- @Test
- void everySourceUsesUnixLineEndings() throws IOException {
- List broken = violations(source -> source.raw().contains("\r\n"));
-
- assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty();
- }
-
- /**
- * 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다.
- */
- @Test
- void noSourceContainsTabCharacters() throws IOException {
- List broken = violations(source -> source.raw().contains("\t"));
-
- assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty();
- }
-
- /**
- * 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다.
- */
- @Test
- void noLineEndsWithWhitespace() throws IOException {
- List broken =
- violations(
- source ->
- source.lines().stream()
- .anyMatch(line -> !line.equals(line.stripTrailing())));
-
- assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty();
- }
-
- /**
- * 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다.
- */
- @Test
- void everySourceEndsWithExactlyOneNewline() throws IOException {
- List broken =
- violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n"));
-
- assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty();
- }
-
- /**
- * 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다.
- *
- * 판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다.
- */
- @Test
- void noSourceKeepsAnUnusedImport() throws IOException {
- List unused = new ArrayList<>();
- for (JavaSource source : sources()) {
- String body =
- String.join(
- "\n",
- source.lines().stream().filter(line -> !line.startsWith("import ")).toList());
- for (String line : source.lines()) {
- Matcher matcher = IMPORT.matcher(line);
- if (matcher.find() && !containsWord(body, matcher.group(1))) {
- unused.add(source.path() + " -> " + matcher.group(1));
- }
- }
- }
-
- assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty();
- }
-
- /**
- * {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다.
- *
- * 비교는 세미콜론을 뗀 경로로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못
- * 판정합니다.
- *
- *
그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만
- * 잠그는 편이 낫습니다.
- */
- @Test
- void importsAreOrderedStaticFirstThenAlphabetically() throws IOException {
- List broken = new ArrayList<>();
- for (JavaSource source : sources()) {
- List statics = new ArrayList<>();
- List regular = new ArrayList<>();
- for (String line : source.lines()) {
- if (line.startsWith("import static ")) {
- statics.add(line.substring("import static ".length()).replace(";", ""));
- } else if (line.startsWith("import ")) {
- regular.add(line.substring("import ".length()).replace(";", ""));
- }
- }
- if (!isSorted(statics) || !isSorted(regular)) {
- broken.add(source.path());
- }
- if (!source.staticImportsComeFirst()) {
- broken.add(source.path() + " (static import가 일반 import 뒤에 있음)");
- }
- }
-
- assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty();
- }
-
- /**
- * 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다.
- */
- @Test
- void theSourceSetIsActuallyScanned() throws IOException {
- assertThat(sources())
- .withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.")
- .hasSizeGreaterThan(50);
- }
-
- /**
- * 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다.
- */
- private List violations(Predicate broken) throws IOException {
- return sources().stream().filter(broken).map(JavaSource::path).toList();
- }
-
- /**
- * 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다.
- */
- private boolean isSorted(List values) {
- return values.equals(values.stream().sorted().toList());
- }
-
- /**
- * 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다.
- */
- private boolean containsWord(String text, String word) {
- return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find();
- }
-
- /**
- * main과 test의 모든 Java 소스를 읽어 옵니다.
- */
- private List sources() throws IOException {
- List sources = new ArrayList<>();
- for (Path root : SOURCE_ROOTS) {
- try (Stream paths = Files.walk(root)) {
- for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) {
- sources.add(
- new JavaSource(
- path.toString().replace('\\', '/'),
- new String(Files.readAllBytes(path), StandardCharsets.UTF_8)));
- }
- }
- }
- return sources;
- }
-
- /**
- * 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다.
- */
- private record JavaSource(String path, String raw) {
-
- /**
- * 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다.
- */
- List lines() {
- return List.of(raw.split("\n", -1));
- }
-
- /**
- * 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다.
- */
- boolean staticImportsComeFirst() {
- List lines = lines();
- int lastStatic = -1;
- int firstRegular = Integer.MAX_VALUE;
- for (int index = 0; index < lines.size(); index++) {
- String line = lines.get(index);
- if (line.startsWith("import static ")) {
- lastStatic = index;
- } else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) {
- firstRegular = index;
- }
- }
- return lastStatic < firstRegular;
- }
- }
-}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java
deleted file mode 100644
index 57f402e..0000000
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/docs/PackageBoundaryContractTest.java
+++ /dev/null
@@ -1,112 +0,0 @@
-package io.shinhanlife.dap.biz.mcp.docs;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.stream.Stream;
-
-import org.junit.jupiter.api.Test;
-
-/**
- * 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿
- * 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
- */
-class PackageBoundaryContractTest {
-
- private static final Path MAIN_SOURCES = Path.of("src", "main", "java");
- /**
- * 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다.
- */
- private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dap/biz/mcp/transport/";
-
- /**
- * 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다.
- */
- @Test
- void servletApiStaysInsideTheTransportPackage() throws IOException {
- List leaks = sourcesImporting("jakarta.servlet").stream()
- .filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE))
- .toList();
-
- assertThat(leaks)
- .withFailMessage(
- "jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n"
- + "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.",
- leaks)
- .isEmpty();
- }
-
- /**
- * 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다.
- */
- @Test
- void transportDoesNotReachIntoExecutionOrRegistry() throws IOException {
- List violations = sourcesImportingAny(List.of(
- "io.shinhanlife.dap.biz.mcp.execute.",
- "io.shinhanlife.dap.biz.mcp.registry."))
- .stream()
- .filter(path -> normalize(path).contains(TRANSPORT_PACKAGE))
- .toList();
-
- assertThat(violations)
- .withFailMessage(
- "transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s",
- violations)
- .isEmpty();
- }
-
- /**
- * main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다.
- */
- private List sourcesImportingAny(List importPrefixes) throws IOException {
- try (Stream paths = Files.walk(MAIN_SOURCES)) {
- return paths.filter(path -> path.toString().endsWith(".java"))
- .filter(path -> declaresAnyImport(path, importPrefixes))
- .toList();
- }
- }
-
- /**
- * main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다.
- */
- private List sourcesImporting(String importPrefix) throws IOException {
- try (Stream paths = Files.walk(MAIN_SOURCES)) {
- return paths.filter(path -> path.toString().endsWith(".java"))
- .filter(path -> declaresImport(path, importPrefix))
- .toList();
- }
- }
-
- /**
- * 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다.
- */
- private boolean declaresImport(Path path, String importPrefix) {
- try (Stream lines = Files.lines(path)) {
- return lines.anyMatch(line -> line.startsWith("import " + importPrefix));
- } catch (IOException exception) {
- throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
- }
- }
-
- /**
- * 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다.
- */
- private boolean declaresAnyImport(Path path, List importPrefixes) {
- try (Stream lines = Files.lines(path)) {
- return lines.anyMatch(line -> importPrefixes.stream()
- .anyMatch(importPrefix -> line.startsWith("import " + importPrefix)));
- } catch (IOException exception) {
- throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
- }
- }
-
- /**
- * OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다.
- */
- private String normalize(Path path) {
- return path.toString().replace('\\', '/');
- }
-}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/McpServerApplicationTest.java
similarity index 83%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/McpServerApplicationTest.java
index 992b091..5a62c4c 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/McpServerApplicationTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/McpServerApplicationTest.java
@@ -1,6 +1,6 @@
-package io.shinhanlife.dap.biz.mcp;
+package io.shinhanlife.dat.biz.mcp;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryClient;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryClient;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java b/src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java
similarity index 75%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java
index 826b8a8..7dd72fa 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/TestFixtures.java
@@ -1,15 +1,20 @@
-package io.shinhanlife.dap.biz.mcp;
+package io.shinhanlife.dat.biz.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
import java.util.List;
public final class TestFixtures {
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ public static final String GUID = "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63";
+ public static final String REQUEST_ID = "7c8b5b20-302d-4e30-a850-69a45df26f3d";
private TestFixtures() {
}
@@ -43,11 +48,19 @@ public final class TestFixtures {
public static McpRequestContext context() {
return new McpRequestContext(
"external",
- "req-1",
- "guid-1",
+ GUID,
+ "12345678",
+ REQUEST_ID,
+ OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
+ null,
+ "DAH",
+ "AXHUB",
+ "10.10.10.1",
+ "10.10.10.2",
+ "agent-host-1",
+ "SCRN",
+ "agent-public-1",
"session-1",
- "ENC(employee-1)",
- "ENC(virtual-1)",
"Bearer test-token",
Instant.parse("2030-01-01T00:00:00Z"));
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/config/McpBundleConfigurationTest.java
similarity index 95%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/config/McpBundleConfigurationTest.java
index 5babe9b..ea08bfa 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/config/McpBundleConfigurationTest.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.config;
+package io.shinhanlife.dat.biz.mcp.config;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.bundle;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java
similarity index 91%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java
index 7d7d121..d897931 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/AgentBuilderContractExampleTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/contract/AgentBuilderContractExampleTest.java
@@ -1,8 +1,8 @@
-package io.shinhanlife.dap.biz.mcp.contract;
+package io.shinhanlife.dat.biz.mcp.contract;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@@ -11,18 +11,18 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
-import io.shinhanlife.dap.biz.mcp.execute.ToolArgumentValidator;
-import io.shinhanlife.dap.biz.mcp.execute.ToolCall;
-import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
-import io.shinhanlife.dap.biz.mcp.method.InitializeHandler;
-import io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler;
-import io.shinhanlife.dap.biz.mcp.method.ToolsListHandler;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.execute.ToolArgumentValidator;
+import io.shinhanlife.dat.biz.mcp.execute.ToolCall;
+import io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.method.InitializeHandler;
+import io.shinhanlife.dat.biz.mcp.method.ToolsCallHandler;
+import io.shinhanlife.dat.biz.mcp.method.ToolsListHandler;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/contract/ToolBundleContractExampleTest.java
similarity index 93%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/contract/ToolBundleContractExampleTest.java
index fd817da..7338130 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/ToolBundleContractExampleTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/contract/ToolBundleContractExampleTest.java
@@ -1,16 +1,16 @@
-package io.shinhanlife.dap.biz.mcp.contract;
+package io.shinhanlife.dat.biz.mcp.contract;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.bundle;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import java.lang.reflect.RecordComponent;
import java.nio.file.Files;
import java.nio.file.Path;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java
similarity index 99%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java
index a802bfa..1919cab 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/deploy/HelmDeploymentContractTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.deploy;
+package io.shinhanlife.dat.biz.mcp.deploy;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java
similarity index 91%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java
index 576b80a..4282a78 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidatorTest.java
@@ -1,13 +1,13 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import org.junit.jupiter.api.Test;
class ToolArgumentValidatorTest {
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java
similarity index 65%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java
index 9f3d0c5..22bd02c 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolExecutionServiceTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java
@@ -1,8 +1,8 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.tool;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
@@ -10,14 +10,15 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolResponse;
import org.junit.jupiter.api.Test;
class ToolExecutionServiceTest {
@@ -28,23 +29,23 @@ class ToolExecutionServiceTest {
ToolArgumentValidator validator = mock(ToolArgumentValidator.class);
ToolRoutingService routing = mock(ToolRoutingService.class);
ToolClient client = mock(ToolClient.class);
- ToolCall call =
- new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}"));
+ McpRequestContext requestContext = context();
+ ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}"));
ToolMetadata metadata = tool("http://tool/one");
ToolRequest request =
new ToolRequest("customer.search", "1.0.0", "http://tool/one", call.arguments(), 3_000);
- when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
+ when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata);
when(routing.route(call, metadata)).thenReturn(request);
- when(client.execute(request, context()))
+ when(client.execute(request, requestContext))
.thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"order\":1}")));
ToolExecutionService service =
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
- var result = service.execute(call, context());
+ var result = service.execute(call, requestContext);
assertThat(result.data().path("order").asInt()).isEqualTo(1);
verify(validator).validate(call, metadata);
- verify(client).execute(request, context());
+ verify(client).execute(request, requestContext);
}
@Test
@@ -53,22 +54,21 @@ class ToolExecutionServiceTest {
ToolArgumentValidator validator = mock(ToolArgumentValidator.class);
ToolRoutingService routing = mock(ToolRoutingService.class);
ToolClient client = mock(ToolClient.class);
+ McpRequestContext requestContext = context();
ToolCall call = new ToolCall("weather", OBJECT_MAPPER.readTree("{\"city\":\"Seoul\"}"));
ToolMetadata metadata = tool("http://tool");
ToolRequest request =
- new ToolRequest(
- "weather", metadata.version(), "http://tool/weather", call.arguments(), 3_000);
- when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
+ new ToolRequest("weather", metadata.version(), "http://tool/weather", call.arguments(), 3_000);
+ when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata);
when(routing.route(call, metadata)).thenReturn(request);
- when(client.execute(request, context()))
- .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{}")));
+ when(client.execute(request, requestContext)).thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{}")));
ToolExecutionService service =
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
- service.execute(call, context());
+ service.execute(call, requestContext);
verify(validator).validate(call, metadata);
- verify(client).execute(request, context());
+ verify(client).execute(request, requestContext);
}
@Test
@@ -77,13 +77,14 @@ class ToolExecutionServiceTest {
ToolArgumentValidator validator = mock(ToolArgumentValidator.class);
ToolRoutingService routing = mock(ToolRoutingService.class);
ToolClient client = mock(ToolClient.class);
+ McpRequestContext requestContext = context();
ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}"));
ToolMetadata metadata = tool("http://tool/removed");
ToolRequest request =
new ToolRequest("customer.search", "1.0.0", "http://tool/removed", call.arguments(), 3_000);
- when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
+ when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata);
when(routing.route(call, metadata)).thenReturn(request);
- when(client.execute(request, context()))
+ when(client.execute(request, requestContext))
.thenThrow(new ToolClientException(
ToolClientException.Kind.EXECUTION,
"Tool returned HTTP 404: customer.search",
@@ -92,11 +93,11 @@ class ToolExecutionServiceTest {
ToolExecutionService service =
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
- assertThatThrownBy(() -> service.execute(call, context()))
+ assertThatThrownBy(() -> service.execute(call, requestContext))
.isInstanceOf(JsonRpcException.class)
.hasMessageContaining("Tool returned HTTP 404");
- verify(registry).refresh(context().routeKey());
+ verify(registry).refresh(requestContext.routeKey());
}
@Test
@@ -105,13 +106,14 @@ class ToolExecutionServiceTest {
ToolArgumentValidator validator = mock(ToolArgumentValidator.class);
ToolRoutingService routing = mock(ToolRoutingService.class);
ToolClient client = mock(ToolClient.class);
+ McpRequestContext requestContext = context();
ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}"));
ToolMetadata metadata = tool("http://tool/error");
ToolRequest request =
new ToolRequest("customer.search", "1.0.0", "http://tool/error", call.arguments(), 3_000);
- when(registry.findEnabledTool(context().routeKey(), call.toolName())).thenReturn(metadata);
+ when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata);
when(routing.route(call, metadata)).thenReturn(request);
- when(client.execute(request, context()))
+ when(client.execute(request, requestContext))
.thenThrow(new ToolClientException(
ToolClientException.Kind.EXECUTION,
"Tool returned HTTP 500: customer.search",
@@ -120,10 +122,10 @@ class ToolExecutionServiceTest {
ToolExecutionService service =
new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class));
- assertThatThrownBy(() -> service.execute(call, context()))
+ assertThatThrownBy(() -> service.execute(call, requestContext))
.isInstanceOf(JsonRpcException.class)
.hasMessageContaining("Tool returned HTTP 500");
- verify(registry, never()).refresh(context().routeKey());
+ verify(registry, never()).refresh(requestContext.routeKey());
}
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java
similarity index 87%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java
index d9d3765..1cb13aa 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolRoutingServiceTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.execute;
+package io.shinhanlife.dat.biz.mcp.execute;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
-import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
+import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata;
import org.junit.jupiter.api.Test;
class ToolRoutingServiceTest {
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java
similarity index 97%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java
index 8aa8932..c5ff754 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParserTest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.jsonrpc;
+package io.shinhanlife.dat.biz.mcp.jsonrpc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java
similarity index 74%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java
index f07686e..fefcb8c 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializeHandlerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java
@@ -1,11 +1,11 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import org.junit.jupiter.api.Test;
class InitializeHandlerTest {
@@ -19,16 +19,16 @@ class InitializeHandlerTest {
JsonNodeFactory.instance.objectNode(),
JsonNodeFactory.instance.numberNode(1));
- var response = handler.handle(request, io.shinhanlife.dap.biz.mcp.TestFixtures.context());
+ var response = handler.handle(request, io.shinhanlife.dat.biz.mcp.TestFixtures.context());
assertThat(response.jsonrpc()).isEqualTo("2.0");
assertThat(response.id().asInt()).isEqualTo(1);
assertThat(response.result()).isInstanceOf(McpSchema.InitializeResult.class);
com.fasterxml.jackson.databind.JsonNode serialized =
- io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
+ io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
assertThat(serialized)
.isEqualTo(
- io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.readTree(
+ io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER.readTree(
"""
{
"protocolVersion":"2025-11-25",
@@ -47,13 +47,15 @@ class InitializeHandlerTest {
InitializeHandler handler = new InitializeHandler(properties(false, false));
JsonRpcRequest request = new JsonRpcRequest(
"initialize", JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(2));
- var base = io.shinhanlife.dap.biz.mcp.TestFixtures.context();
- var othContext = new io.shinhanlife.dap.biz.mcp.context.McpRequestContext(
- "oth", base.requestId(), base.guid(), base.mcpSessionId(), base.employeeNo(),
- base.virtualEmployeeNo(), base.authorization(), base.deadline());
+ var base = io.shinhanlife.dat.biz.mcp.TestFixtures.context();
+ var othContext = new io.shinhanlife.dat.biz.mcp.context.McpRequestContext(
+ "oth", base.guid(), base.prafNo(), base.requestId(), base.requestTime(),
+ base.virtualPrafNo(), base.appCode(), base.projectCode(), base.userIp(), base.callerIp(),
+ base.callerHost(), base.channel(), base.agentId(), base.mcpSessionId(), base.authorization(),
+ base.deadline());
var response = handler.handle(request, othContext);
- var serialized = io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
+ var serialized = io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result());
assertThat(serialized.path("serverInfo").path("name").asText())
.isEqualTo("shl-axhub-mcp-server-oth");
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandlerTest.java
similarity index 81%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandlerTest.java
index ed91ee8..44999fd 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/InitializedNotificationHandlerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandlerTest.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import org.junit.jupiter.api.Test;
class InitializedNotificationHandlerTest {
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java
similarity index 94%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java
index 984f899..ebdb6fc 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsCallHandlerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandlerTest.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -11,11 +11,11 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.JsonNode;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.execute.ToolCall;
-import io.shinhanlife.dap.biz.mcp.execute.ToolExecutionService;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.execute.ToolCall;
+import io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandlerTest.java
similarity index 91%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandlerTest.java
index ec163f2..4067910 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/method/ToolsListHandlerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandlerTest.java
@@ -1,15 +1,15 @@
-package io.shinhanlife.dap.biz.mcp.method;
+package io.shinhanlife.dat.biz.mcp.method;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.tool;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.modelcontextprotocol.spec.McpSchema;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -57,7 +57,7 @@ class ToolsListHandlerTest {
"annotations":{"readOnlyHint":true}}
""");
var metadata =
- new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata(
+ new io.shinhanlife.dat.biz.mcp.registry.ToolMetadata(
"weather",
"1.0.0",
"weather",
@@ -82,7 +82,7 @@ class ToolsListHandlerTest {
void normalizesMissingRegistryInputSchemaToAnEmptyObjectSchema() throws Exception {
ToolRegistryService registryService = mock(ToolRegistryService.class);
var metadata =
- new io.shinhanlife.dap.biz.mcp.registry.ToolMetadata(
+ new io.shinhanlife.dat.biz.mcp.registry.ToolMetadata(
"legacy.lookup",
"1.0.0",
"Legacy lookup",
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/HealthGroupContractTest.java
similarity index 98%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/observability/HealthGroupContractTest.java
index 1941bcc..afbed40 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/HealthGroupContractTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/HealthGroupContractTest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpointTest.java
similarity index 82%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpointTest.java
index fa0b00c..42a5e99 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolBundleStatusEndpointTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpointTest.java
@@ -1,11 +1,11 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
import java.util.List;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java
similarity index 91%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java
index 4234abb..2fbfd2e 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java
@@ -1,11 +1,11 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryRefreshScheduler;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Status;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/TraceLoggerTest.java
similarity index 75%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/observability/TraceLoggerTest.java
index 9e7d760..fde92c7 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/observability/TraceLoggerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/TraceLoggerTest.java
@@ -1,12 +1,12 @@
-package io.shinhanlife.dap.biz.mcp.observability;
+package io.shinhanlife.dat.biz.mcp.observability;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
@@ -36,8 +36,8 @@ class TraceLoggerTest {
assertThat(event.getFormattedMessage())
.contains(
"event=mcp_http_response_completed",
- "guid=guid-1",
- "requestId=req-1",
+ "guid=3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63",
+ "requestId=7c8b5b20-302d-4e30-a850-69a45df26f3d",
"httpStatus=200"));
logger.detachAppender(appender);
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java
similarity index 84%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java
index 96f8a37..659a957 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/LocalFileToolRegistryClientTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java
similarity index 51%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java
index 2251d7f..e621159 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java
@@ -1,7 +1,7 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
@@ -9,7 +9,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -126,6 +126,55 @@ class PortalToolRegistryClientTest {
assertThat(toolServer.getRequestCount()).isEqualTo(1);
}
+ @Test
+ void keepsAvailablePortalToolServiceWhenAnotherServiceHasNoSnapshot() throws Exception {
+ MockWebServer unreachableToolServer = new MockWebServer();
+ unreachableToolServer.start();
+ try {
+ portal.enqueue(portalRegistryWithTwoToolServices("portal-1", unreachableToolServer));
+ unreachableToolServer.enqueue(new MockResponse().setResponseCode(503));
+ toolServer.enqueue(manifest("manifest-1", "external.weather"));
+ PortalToolRegistryClient client = client();
+
+ Map> snapshots = client.fetchAllTools();
+
+ assertThat(snapshots.get("external"))
+ .extracting(ToolMetadata::name)
+ .containsExactly("external.weather");
+ assertThat(unreachableToolServer.getRequestCount()).isEqualTo(1);
+ assertThat(toolServer.getRequestCount()).isEqualTo(1);
+ } finally {
+ unreachableToolServer.shutdown();
+ }
+ }
+
+ @Test
+ void keepsUsablePortalToolServiceWhenOtherServicesHaveNoEndpointMetadata() {
+ portal.enqueue(portalRegistryWithOnlyOneUsableToolService("portal-1"));
+ toolServer.enqueue(manifest("manifest-1", "external.weather"));
+ PortalToolRegistryClient client = client();
+
+ Map> snapshots = client.fetchAllTools();
+
+ assertThat(snapshots.get("external"))
+ .extracting(ToolMetadata::name)
+ .containsExactly("external.weather");
+ assertThat(toolServer.getRequestCount()).isEqualTo(1);
+ }
+ @Test
+ void keepsUsableRouteWhenEarlierRoutesHaveNoEndpointMetadata() {
+ portal.enqueue(portalRegistryWithOnlySysRouteUsable("portal-1"));
+ toolServer.enqueue(manifestForBundle("was-sys", "sys.health"));
+ PortalToolRegistryClient client = client();
+
+ Map> snapshots = client.fetchAllTools();
+
+ assertThat(snapshots).containsOnlyKeys("sys");
+ assertThat(snapshots.get("sys"))
+ .extracting(ToolMetadata::name)
+ .containsExactly("sys.health");
+ assertThat(toolServer.getRequestCount()).isEqualTo(1);
+ }
@Test
void rejectsBlankRouteInsteadOfUsingConfiguredDefaultRoute() {
@@ -194,11 +243,142 @@ class PortalToolRegistryClientTest {
.formatted(revision, toolServer.url("").toString().replaceAll("/+$", ""));
}
- private MockResponse manifest(String revision, String toolName) {
+ private MockResponse portalRegistryWithTwoToolServices(String revision, MockWebServer unreachableToolServer) {
return jsonResponse(
"""
{
- "bundleId": "external-tool-server",
+ "registryRevision": "%s",
+ "routes": [
+ {
+ "routeKey": "external",
+ "toolServices": [
+ {
+ "serviceKey": "unreachable-tool-server",
+ "serviceDomain": "%s",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "unreachable.",
+ "status": "ACTIVE"
+ },
+ {
+ "serviceKey": "external-tool-server",
+ "serviceDomain": "%s",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "external.",
+ "status": "ACTIVE"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .formatted(
+ revision,
+ unreachableToolServer.url("").toString().replaceAll("/+$", ""),
+ toolServer.url("").toString().replaceAll("/+$", "")));
+ }
+
+ private MockResponse portalRegistryWithOnlyOneUsableToolService(String revision) {
+ return jsonResponse(
+ """
+ {
+ "registryRevision": "%s",
+ "routes": [
+ {
+ "routeKey": "external",
+ "toolServices": [
+ {
+ "serviceKey": "missing-domain",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "missingDomain.",
+ "status": "ACTIVE"
+ },
+ {
+ "serviceKey": "missing-manifest-path",
+ "serviceDomain": "http://missing-manifest-path.example",
+ "namePrefix": "missingManifest.",
+ "status": "ACTIVE"
+ },
+ {
+ "serviceDomain": "http://missing-service-key.example",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "missingKey.",
+ "status": "ACTIVE"
+ },
+ {
+ "serviceKey": "external-tool-server",
+ "serviceDomain": "%s",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "external.",
+ "status": "ACTIVE"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .formatted(revision, toolServer.url("").toString().replaceAll("/+$", "")));
+ }
+
+ private MockResponse portalRegistryWithOnlySysRouteUsable(String revision) {
+ return jsonResponse(
+ """
+ {
+ "registryRevision": "%s",
+ "routes": [
+ {
+ "routeKey": "cus",
+ "toolServices": [ {
+ "serviceKey": "was-cus",
+ "serviceDomain": "",
+ "manifestPath": "/tool-manifest",
+ "status": "ACTIVE"
+ } ]
+ },
+ {
+ "routeKey": "sal",
+ "toolServices": [ {
+ "serviceKey": "was-sal",
+ "serviceDomain": "",
+ "manifestPath": "/tool-manifest",
+ "status": "ACTIVE"
+ } ]
+ },
+ {
+ "routeKey": "pro",
+ "toolServices": [ {
+ "serviceKey": "was-pro",
+ "serviceDomain": "",
+ "manifestPath": "/tool-manifest",
+ "status": "ACTIVE"
+ } ]
+ },
+ {
+ "routeKey": "sys",
+ "toolServices": [ {
+ "serviceKey": "was-sys",
+ "serviceDomain": "%s",
+ "manifestPath": "/tool-manifest",
+ "status": "ACTIVE"
+ } ]
+ }
+ ]
+ }
+ """
+ .formatted(revision, toolServer.url("").toString().replaceAll("/+$", "")));
+ }
+ private MockResponse manifest(String revision, String toolName) {
+ return manifestForBundle("external-tool-server", revision, toolName);
+ }
+
+ private MockResponse manifestForBundle(String bundleId, String toolName) {
+ return manifestForBundle(bundleId, "manifest-1", toolName);
+ }
+
+ private MockResponse manifestForBundle(String bundleId, String revision, String toolName) {
+ return jsonResponse(
+ """
+ {
+ "bundleId": "%s",
"revision": "%s",
"tools": [
{
@@ -221,7 +401,7 @@ class PortalToolRegistryClientTest {
]
}
"""
- .formatted(revision, toolName, toolName));
+ .formatted(bundleId, revision, toolName, toolName));
}
private MockResponse jsonResponse(String body) {
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCacheTest.java
similarity index 84%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCacheTest.java
index 9e65ab3..1a84bf7 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/RedisToolRegistryCacheTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCacheTest.java
@@ -1,8 +1,8 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.tool;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
@@ -13,6 +13,7 @@ import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.Test;
+import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
@@ -24,7 +25,7 @@ class RedisToolRegistryCacheTest {
StringRedisTemplate template = mock(StringRedisTemplate.class);
ValueOperations values = mock(ValueOperations.class);
when(template.opsForValue()).thenReturn(values);
- when(values.get(any())).thenThrow(new IllegalStateException("redis unavailable"));
+ when(values.get(any())).thenThrow(new DataAccessResourceFailureException("redis unavailable"));
RedisToolRegistryCache cache =
new RedisToolRegistryCache(template, OBJECT_MAPPER, properties(true, false));
@@ -36,7 +37,7 @@ class RedisToolRegistryCacheTest {
StringRedisTemplate template = mock(StringRedisTemplate.class);
ValueOperations values = mock(ValueOperations.class);
when(template.opsForValue()).thenReturn(values);
- org.mockito.Mockito.doThrow(new IllegalStateException("redis unavailable"))
+ org.mockito.Mockito.doThrow(new DataAccessResourceFailureException("redis unavailable"))
.when(values)
.set(any(), any(), any(Duration.class));
RedisToolRegistryCache cache =
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java
similarity index 94%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java
index 6609973..938f085 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java
@@ -1,15 +1,15 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.bundle;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.bundle;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleStatus;
import java.time.Duration;
import java.util.List;
@@ -131,7 +131,7 @@ class ToolBundleDiscoveryTest {
}
@Test
- void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() {
+ void keepsAvailableBundleWhenAnotherBundleHasNoLastGoodSnapshot() {
alpha.enqueue(new MockResponse().setResponseCode(503));
beta.enqueue(manifest("bundle-a", "a.only"));
McpProperties properties =
@@ -139,12 +139,11 @@ class ToolBundleDiscoveryTest {
bundle("bundle-b", url(alpha), "http://tool-b/mcp", "b."),
bundle("bundle-a", url(beta), "http://tool-a/mcp", "a."));
- assertThatThrownBy(() -> client(properties).fetchTools())
- .isInstanceOfSatisfying(
- JsonRpcException.class,
- exception ->
- assertThat(exception.errorCode())
- .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
+ List tools = client(properties).fetchTools();
+
+ assertThat(tools)
+ .extracting(ToolMetadata::name)
+ .containsExactly("a.only");
}
@Test
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java
similarity index 97%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java
index 31d14a8..bf3316a 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleRegistryWiringTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
similarity index 96%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
index c5a3641..7cd2254 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
@@ -1,4 +1,4 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java
similarity index 97%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java
index b5580b3..b2dcb38 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java
@@ -1,6 +1,6 @@
-package io.shinhanlife.dap.biz.mcp.registry;
+package io.shinhanlife.dat.biz.mcp.registry;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.tool;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.tool;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -13,8 +13,8 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java
similarity index 59%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java
index 5721a91..28dd0af 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/toolclient/HttpToolClientTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java
@@ -1,16 +1,32 @@
-package io.shinhanlife.dap.biz.mcp.toolclient;
+package io.shinhanlife.dat.biz.mcp.toolclient;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.GUID;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.REQUEST_ID;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_AGENT_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_APP_CODE;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CALLER_HOST;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CALLER_IP;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CHANNEL;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_GUID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_PRAF_NO;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_PROJECT_CODE;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_TIME;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_USER_IP;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_VRTL_PRAF_NO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
-import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest;
+import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolResponse;
import java.net.http.HttpClient;
import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.util.UUID;
import java.util.concurrent.TimeUnit;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
@@ -35,7 +51,7 @@ class HttpToolClientTest {
}
@Test
- void postsJsonAndPropagatesCorrelationHeadersWithoutAuthorization() throws Exception {
+ void postsJsonAndPropagatesStandardHeadersWithoutAuthorization() throws Exception {
server.enqueue(
new MockResponse()
.setHeader("Content-Type", "application/json")
@@ -56,12 +72,20 @@ class HttpToolClientTest {
RecordedRequest recorded = server.takeRequest(1, TimeUnit.SECONDS);
assertThat(recorded).isNotNull();
assertThat(recorded.getMethod()).isEqualTo("POST");
- // 다섯 헤더 모두 이름·값을 바꾸지 않고 그대로 bypass한다.
- assertThat(recorded.getHeader("guid")).isEqualTo("guid-1");
- assertThat(recorded.getHeader("x-request-id")).isEqualTo("req-1");
+ assertThat(recorded.getHeader(HEADER_GUID)).isEqualTo(GUID);
+ assertThat(recorded.getHeader(HEADER_PRAF_NO)).isEqualTo("12345678");
+ assertThat(recorded.getHeader(HEADER_REQUEST_ID)).isNotEqualTo(REQUEST_ID);
+ assertThat(UUID.fromString(recorded.getHeader(HEADER_REQUEST_ID)).version()).isEqualTo(4);
+ assertThat(OffsetDateTime.parse(recorded.getHeader(HEADER_REQUEST_TIME))).isNotNull();
+ assertThat(recorded.getHeader(HEADER_VRTL_PRAF_NO)).isNull();
+ assertThat(recorded.getHeader(HEADER_APP_CODE)).isEqualTo("DAH");
+ assertThat(recorded.getHeader(HEADER_PROJECT_CODE)).isEqualTo("AXHUB");
+ assertThat(recorded.getHeader(HEADER_USER_IP)).isEqualTo("10.10.10.1");
+ assertThat(recorded.getHeader(HEADER_CALLER_IP)).isNotBlank();
+ assertThat(recorded.getHeader(HEADER_CALLER_HOST)).isNotBlank();
+ assertThat(recorded.getHeader(HEADER_CHANNEL)).isEqualTo("MCP");
+ assertThat(recorded.getHeader(HEADER_AGENT_ID)).isEqualTo("agent-public-1");
assertThat(recorded.getHeader("mcp-session-id")).isEqualTo("session-1");
- assertThat(recorded.getHeader("employee-no")).isEqualTo("ENC(employee-1)");
- assertThat(recorded.getHeader("virtual-employee-no")).isEqualTo("ENC(virtual-1)");
assertThat(recorded.getHeader("X-Tool-Server-API-Key")).isEqualTo("tool-server-key");
assertThat(recorded.getHeader("x-trace-id")).isNull();
assertThat(recorded.getHeader("Authorization")).isNull();
@@ -80,9 +104,11 @@ class HttpToolClientTest {
"smp_quote_daily", "1.0.0", server.url("/mcp/smp_quote_daily").toString(),
OBJECT_MAPPER.readTree("{\"category\":\"속담\"}"), 3_000);
var base = context();
- var othContext = new io.shinhanlife.dap.biz.mcp.context.McpRequestContext(
- "oth", base.requestId(), base.guid(), base.mcpSessionId(), base.employeeNo(),
- base.virtualEmployeeNo(), base.authorization(), Instant.now().plusSeconds(10));
+ var othContext = new io.shinhanlife.dat.biz.mcp.context.McpRequestContext(
+ "oth", base.guid(), base.prafNo(), base.requestId(), base.requestTime(),
+ base.virtualPrafNo(), base.appCode(), base.projectCode(), base.userIp(), base.callerIp(),
+ base.callerHost(), base.channel(), base.agentId(), base.mcpSessionId(), base.authorization(),
+ Instant.now().plusSeconds(10));
client.execute(request, othContext);
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java
similarity index 85%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java
index a0857da..75cef75 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpControllerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpControllerTest.java
@@ -1,18 +1,18 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequestParser;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
-import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry;
-import io.shinhanlife.dap.biz.mcp.method.McpMethodHandlerRegistry.Handler;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequestParser;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse;
+import io.shinhanlife.dat.biz.mcp.method.McpMethodHandlerRegistry;
+import io.shinhanlife.dat.biz.mcp.method.McpMethodHandlerRegistry.Handler;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
@@ -41,7 +41,7 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
- new McpController(parser, registry, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
+ new McpController(parser, registry, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isNull();
@@ -64,7 +64,7 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
- new McpController(parser, registry, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
+ new McpController(parser, registry, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
String sessionId = response.getHeaders().getFirst(McpController.MCP_SESSION_ID_HEADER);
assertThat(sessionId).isNotBlank();
@@ -89,7 +89,7 @@ class McpControllerTest {
McpRequestContextHolder.set(context());
var response =
- new McpController(parser, registry, io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
+ new McpController(parser, registry, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER).handleMcpRequest(JsonNodeFactory.instance.objectNode());
PostMapping mapping =
McpController.class
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java
similarity index 65%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java
index 866307c..cbac132 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpEndpointMethodContractTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpEndpointMethodContractTest.java
@@ -1,5 +1,18 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.GUID;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.REQUEST_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_AGENT_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_APP_CODE;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CALLER_HOST;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CALLER_IP;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CHANNEL;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_GUID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_PRAF_NO;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_PROJECT_CODE;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_TIME;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_USER_IP;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -9,7 +22,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryClient;
+import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryClient;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -17,12 +33,14 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
/**
- * 배포 설정의 단일 MCP endpoint를 실제 HTTP dispatch 경로로 검증하는 계약 테스트입니다. 예시 배포는 공개 경로 {@code /mcp/core}를 rewrite 없이 직접 처리합니다. MCP 클라이언트가 GET·DELETE를 시도하면 JSON-RPC 오류가
- * 아니라 표준 405로 끝나는지도 filter·DispatcherServlet·ControllerAdvice를 모두 태워 확인합니다. Registry는 이 계약과 무관하므로 mock으로 대체합니다.
+ * 배포 설정의 단일 MCP endpoint를 실제 HTTP dispatch 경로로 검증하는 계약 테스트입니다.
+ * 예시 배포는 공개 경로 {@code /mcp/core}를 rewrite 없이 직접 처리하며, POST 요청에는 Agent Builder 표준 헤더 계약을 포함합니다.
+ * MCP 클라이언트가 GET·DELETE를 시도하면 JSON-RPC 오류가 아니라 표준 405로 끝나는지도 filter·DispatcherServlet·ControllerAdvice를 모두 태워 확인합니다.
*/
@SpringBootTest(properties = "mcp.endpoint-path=/mcp/core")
class McpEndpointMethodContractTest {
@@ -51,7 +69,6 @@ class McpEndpointMethodContractTest {
@Test
void getMcpReturns405ForEveryAcceptHeader() throws Exception {
- // Accept 협상 결과와 무관하게 405여야 한다. 과거에는 Accept가 없으면 HTTP 200 + JSON-RPC -32603이었다.
mockMvc
.perform(get("/mcp/core"))
.andExpect(status().isMethodNotAllowed())
@@ -93,10 +110,9 @@ class McpEndpointMethodContractTest {
@Test
void postMcpStillServesInitialize() throws Exception {
- // 405 처리가 정상 POST 경로를 막지 않는지 확인하는 회귀 방어선이다.
mockMvc
.perform(
- post("/mcp/core")
+ withStandardHeaders(post("/mcp/core"))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM)
.content(
@@ -111,12 +127,11 @@ class McpEndpointMethodContractTest {
.andExpect(jsonPath("$.id").value("init-1"));
}
-
@Test
void fixedEndpointPathRejectsAdditionalDynamicRouteSegment() throws Exception {
mockMvc
.perform(
- post("/mcp/core/external")
+ withStandardHeaders(post("/mcp/core/external"))
.contentType(MediaType.APPLICATION_JSON)
.header("MCP-Protocol-Version", "2025-11-25")
.content("""
@@ -126,6 +141,7 @@ class McpEndpointMethodContractTest {
.andExpect(jsonPath("$.error.code").value(-32600))
.andExpect(jsonPath("$.error.data.details").value("route key is not allowed for fixed endpoint path"));
}
+
@Test
void fixedRootPathIsNotAnAliasForTheConfiguredEndpoint() throws Exception {
mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}"))
@@ -136,11 +152,30 @@ class McpEndpointMethodContractTest {
void configuredEndpointStillRequiresProtocolVersionAfterInitialize() throws Exception {
mockMvc
.perform(
- post("/mcp/core")
+ withStandardHeaders(post("/mcp/core"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"list-1"}
"""))
.andExpect(status().isBadRequest());
}
+
+ /**
+ * MockMvc POST 요청에 Agent Builder 표준 헤더를 붙입니다.
+ * 테스트 대상이 아닌 헤더 누락으로 필터에서 먼저 거부되지 않게 해, 각 테스트가 의도한 HTTP 계약만 검증하도록 합니다.
+ */
+ private MockHttpServletRequestBuilder withStandardHeaders(MockHttpServletRequestBuilder builder) {
+ return builder
+ .header(HEADER_GUID, GUID)
+ .header(HEADER_PRAF_NO, "12345678")
+ .header(HEADER_REQUEST_ID, REQUEST_ID)
+ .header(HEADER_REQUEST_TIME, OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
+ .header(HEADER_APP_CODE, "DAH")
+ .header(HEADER_PROJECT_CODE, "AXHUB")
+ .header(HEADER_USER_IP, "10.10.10.1")
+ .header(HEADER_CALLER_IP, "10.10.10.2")
+ .header(HEADER_CALLER_HOST, "agent-host-1")
+ .header(HEADER_CHANNEL, "SCRN")
+ .header(HEADER_AGENT_ID, "agent-public-1");
+ }
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java
similarity index 88%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java
index 2bccbaa..8bf77d8 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExceptionHandlerTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandlerTest.java
@@ -1,15 +1,15 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.context;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.context;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.fasterxml.jackson.databind.node.TextNode;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
-import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
-import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
+import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
+import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -54,7 +54,7 @@ class McpExceptionHandlerTest {
assertThat(entity.getBody().error().message())
.isEqualTo("Invalid params: customerNo is required");
assertThat(entity.getBody().error().data().toString())
- .contains("guid-1", "customerNo is required");
+ .contains("3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63", "customerNo is required");
assertThat(entity.getBody().id().asText()).isEqualTo("req-1");
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java
similarity index 69%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java
index 83930e7..6b8c811 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilterTest.java
@@ -1,20 +1,35 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.GUID;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.REQUEST_ID;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_AGENT_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_APP_CODE;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CALLER_HOST;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CALLER_IP;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_CHANNEL;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_GUID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_PRAF_NO;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_PROJECT_CODE;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_ID;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_TIME;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_USER_IP;
+import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_VRTL_PRAF_NO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
-import io.shinhanlife.dap.biz.mcp.config.McpProperties;
-import io.shinhanlife.dap.biz.mcp.context.McpRequestContextHolder;
-import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
-
+import io.shinhanlife.dat.biz.mcp.config.McpProperties;
+import io.shinhanlife.dat.biz.mcp.context.McpRequestContextHolder;
+import io.shinhanlife.dat.biz.mcp.observability.TraceLogger;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
-
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -26,8 +41,7 @@ class McpExchangeFilterTest {
void propagatesCorrelationAndKeepsRequestBodyReadableWithoutMdc() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
- request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
- request.addHeader("x-request-id", "req-100");
+ addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.setContent(
"""
@@ -53,19 +67,46 @@ class McpExchangeFilterTest {
.getBytes(StandardCharsets.UTF_8));
});
- assertThat(response.getHeader("guid")).isEqualTo("3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
- assertThat(response.getHeader("x-request-id")).isEqualTo("req-100");
+ assertThat(response.getHeader(HEADER_GUID)).isEqualTo(GUID);
+ assertThat(response.getHeader(HEADER_REQUEST_ID)).isEqualTo(REQUEST_ID);
assertThat(response.getHeader("x-trace-id")).isNull();
assertThat(response.getContentAsString()).contains("\"tools\":[]");
}
- /**
- * 다섯 헤더는 모두 선택값이므로, 하나도 없어도 요청이 처리되어야 합니다. 로그 상관이 끊기지 않도록 guid와 requestId만 서버가 만들어 채웁니다.
- */
@Test
- void treatsEveryCallerHeaderAsOptionalAndStillCorrelates() throws Exception {
+ void rejectsMissingRequiredStandardHeaderBeforeController() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ request.addHeader(HEADER_GUID, GUID);
+ request.setContent(
+ """
+ {"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
+ """
+ .getBytes(StandardCharsets.UTF_8));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(
+ request,
+ response,
+ (ignoredRequest, ignoredResponse) -> {
+ throw new AssertionError("Controller chain must not be called without required headers");
+ });
+
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_CHANNEL + " is required");
+ }
+
+ @Test
+ void acceptsBatchCallWithBlankHumanHeaders() throws Exception {
+ McpExchangeFilter filter = filter(properties(false, false));
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
+ request.removeHeader(HEADER_CHANNEL);
+ request.removeHeader(HEADER_PRAF_NO);
+ request.removeHeader(HEADER_USER_IP);
+ request.addHeader(HEADER_CHANNEL, "BATCH");
+ request.addHeader(HEADER_PRAF_NO, "");
+ request.addHeader(HEADER_USER_IP, "");
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.setContent(
"""
@@ -77,16 +118,20 @@ class McpExchangeFilterTest {
filter.doFilter(
request,
response,
- (wrappedRequest, wrappedResponse) -> wrappedResponse.setContentType("application/json"));
+ (wrappedRequest, wrappedResponse) -> {
+ assertThat(McpRequestContextHolder.require().prafNo()).isEmpty();
+ assertThat(McpRequestContextHolder.require().userIp()).isEmpty();
+ wrappedResponse.setContentType("application/json");
+ });
- assertThat(response.getHeader("guid")).isNotBlank();
- assertThat(response.getHeader("x-request-id")).isNotBlank();
+ assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void extractsRouteKeyFromDynamicMcpPath() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/external");
+ addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.setContent(
"""
@@ -106,15 +151,14 @@ class McpExchangeFilterTest {
assertThat(response.getStatus()).isEqualTo(200);
}
- /**
- * 암호화된 사원번호에 개행이 섞이면 downstream 요청 헤더를 조작할 수 있으므로 입력 경계에서 거부합니다. MCP는 값을 해석하지 않지만 그대로 bypass하기 때문에 이 검증이 유일한 방어선입니다.
- */
@Test
void rejectsEmployeeNumberContainingHeaderInjection() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
+ request.removeHeader(HEADER_PRAF_NO);
+ request.addHeader(HEADER_PRAF_NO, "12345678\r\nx-injected: evil");
request.addHeader("MCP-Protocol-Version", "2025-11-25");
- request.addHeader("employee-no", "abc\r\nx-injected: evil");
request.setContent(
"""
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
@@ -126,22 +170,20 @@ class McpExchangeFilterTest {
request,
response,
(ignoredRequest, ignoredResponse) -> {
- throw new AssertionError(
- "Controller chain must not be called for an unsafe employee-no header");
+ throw new AssertionError("Controller chain must not be called for an unsafe X-Praf-No header");
});
- assertThat(response.getContentAsString()).contains("\"code\":-32600");
+ assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_PRAF_NO + " must be 8 digits");
}
- /**
- * 암호문을 임의로 trim하면 복호화가 깨질 수 있으므로 공백이 섞인 값은 변경하지 않고 거부합니다.
- */
@Test
void rejectsEmployeeNumberContainingWhitespaceInsteadOfTrimmingIt() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
+ request.removeHeader(HEADER_PRAF_NO);
+ request.addHeader(HEADER_PRAF_NO, " 12345678 ");
request.addHeader("MCP-Protocol-Version", "2025-11-25");
- request.addHeader("employee-no", " ENC(employee-1) ");
request.setContent(
"""
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
@@ -153,22 +195,20 @@ class McpExchangeFilterTest {
request,
response,
(ignoredRequest, ignoredResponse) -> {
- throw new AssertionError(
- "Controller chain must not be called for an unsafe employee-no header");
+ throw new AssertionError("Controller chain must not be called for an unsafe X-Praf-No header");
});
- assertThat(response.getContentAsString()).contains("\"code\":-32600");
+ assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_PRAF_NO + " must be 8 digits");
}
- /**
- * 공개 계약이 UUID인 guid에 임의 상관 문자열이 들어오면 downstream으로 전파하지 않고 거부합니다.
- */
@Test
- void rejectsGuidThatIsNotUuid() throws Exception {
+ void rejectsGuidThatIsNotUuidV4() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
+ request.removeHeader(HEADER_GUID);
+ request.addHeader(HEADER_GUID, "guid-1");
request.addHeader("MCP-Protocol-Version", "2025-11-25");
- request.addHeader("guid", "guid-1");
request.setContent(
"""
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
@@ -183,13 +223,54 @@ class McpExchangeFilterTest {
throw new AssertionError("Controller chain must not be called for a non-UUID guid");
});
- assertThat(response.getContentAsString()).contains("\"code\":-32600", "guid must be a UUID");
+ assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_GUID + " must be a UUID v4");
+ }
+
+ @Test
+ void rejectsRequestTimeOutsideFiveMinutes() throws Exception {
+ McpExchangeFilter filter = filter(properties(false, false));
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
+ request.removeHeader(HEADER_REQUEST_TIME);
+ request.addHeader(HEADER_REQUEST_TIME, "2000-01-01T00:00:00+09:00");
+ request.setContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}".getBytes(StandardCharsets.UTF_8));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(
+ request,
+ response,
+ (ignoredRequest, ignoredResponse) -> {
+ throw new AssertionError("Controller chain must not be called for stale request time");
+ });
+
+ assertThat(response.getContentAsString()).contains("\"code\":-32600", "within 5 minutes");
+ }
+
+ @Test
+ void requiresVirtualEmployeeNoForPartnerEmployee() throws Exception {
+ McpExchangeFilter filter = filter(properties(false, false));
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
+ request.removeHeader(HEADER_PRAF_NO);
+ request.addHeader(HEADER_PRAF_NO, "09861234");
+ request.setContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}".getBytes(StandardCharsets.UTF_8));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filter.doFilter(
+ request,
+ response,
+ (ignoredRequest, ignoredResponse) -> {
+ throw new AssertionError("Controller chain must not be called without virtual employee number");
+ });
+
+ assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_VRTL_PRAF_NO + " is required");
}
@Test
void acceptsEventStreamHeaderWithoutChangingJsonResponse() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
request.addHeader("Accept", "application/json, text/event-stream");
request.setContent(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}"
@@ -229,6 +310,7 @@ class McpExchangeFilterTest {
base.bundles());
McpExchangeFilter filter = filter(limited);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
request.setContent("{\"jsonrpc\":\"2.0\"}".getBytes(StandardCharsets.UTF_8));
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -236,8 +318,7 @@ class McpExchangeFilterTest {
request,
response,
(ignoredRequest, ignoredResponse) -> {
- throw new AssertionError(
- "Controller chain must not be called for oversized request bodies");
+ throw new AssertionError("Controller chain must not be called for oversized request bodies");
});
assertThat(response.getStatus()).isEqualTo(200);
@@ -248,6 +329,7 @@ class McpExchangeFilterTest {
void rejectsPostInitializeRequestWithoutProtocolVersionHeader() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
request.setContent(
"""
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
@@ -259,8 +341,7 @@ class McpExchangeFilterTest {
request,
response,
(ignoredRequest, ignoredResponse) -> {
- throw new AssertionError(
- "Controller chain must not be called without MCP-Protocol-Version");
+ throw new AssertionError("Controller chain must not be called without MCP-Protocol-Version");
});
assertThat(response.getStatus()).isEqualTo(400);
@@ -272,6 +353,7 @@ class McpExchangeFilterTest {
void acceptsInitializedNotificationWithProtocolAndSessionHeaders() throws Exception {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.addHeader(McpController.MCP_SESSION_ID_HEADER, "1868a90c-0e2f-4b5c-9f11-3a7d2c8e5b04");
request.setContent(
@@ -290,7 +372,6 @@ class McpExchangeFilterTest {
assertThat(response.getStatus()).isEqualTo(202);
}
-
@Test
void rejectsMissingRouteKeyWhenPortalModeIsEnabled() throws Exception {
McpProperties base = properties(false, false);
@@ -309,6 +390,7 @@ class McpExchangeFilterTest {
base.bundles());
McpExchangeFilter filter = filter(portalEnabled);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.setContent(
"""
@@ -327,6 +409,7 @@ class McpExchangeFilterTest {
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is required");
}
+
@Test
void rejectsRouteKeyThatIsNotRegisteredInPortalSnapshot() throws Exception {
McpProperties base = properties(false, false);
@@ -347,6 +430,7 @@ class McpExchangeFilterTest {
when(routeKeyValidator.isKnownRoute("unknown")).thenReturn(false);
McpExchangeFilter filter = filter(portalEnabled, routeKeyValidator);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/unknown");
+ addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
request.setContent(
"""
@@ -386,6 +470,7 @@ class McpExchangeFilterTest {
when(routeKeyValidator.isKnownRoute("test")).thenReturn(false);
McpExchangeFilter filter = filter(portalEnabled, routeKeyValidator);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp/test");
+ addStandardHeaders(request);
request.setContent(
"""
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}}
@@ -404,15 +489,12 @@ class McpExchangeFilterTest {
assertThat(response.getContentAsString()).contains("\"code\":-32600", "route key is not registered");
}
- /**
- * Agent Builder가 먼저 연결을 끊으면 응답 쓰기가 broken pipe로 실패합니다. 이때 결과가 조용히 사라지지 않도록 별도 event로 기록한 뒤 예외를 그대로 올려야 합니다.
- */
@Test
void recordsUndeliverableResponseWhenTheCallerHasAlreadyDisconnected() {
McpExchangeFilter filter = filter(properties(false, false));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
+ addStandardHeaders(request);
request.addHeader("MCP-Protocol-Version", "2025-11-25");
- request.addHeader("guid", "3f2a91c4-6d0e-4b52-9c17-8ae5d2b40f63");
request.setContent(
"""
{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{}}
@@ -430,8 +512,22 @@ class McpExchangeFilterTest {
}))
.isInstanceOf(IOException.class)
.hasMessageContaining("Broken pipe");
+ }
- // 예외를 삼키면 Tomcat이 연결 정리를 못 하고, 로그가 없으면 유실 자체를 알 수 없다.
+ private void addStandardHeaders(MockHttpServletRequest request) {
+ request.addHeader(HEADER_GUID, GUID);
+ request.addHeader(HEADER_PRAF_NO, "12345678");
+ request.addHeader(HEADER_REQUEST_ID, REQUEST_ID);
+ request.addHeader(
+ HEADER_REQUEST_TIME,
+ OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
+ request.addHeader(HEADER_APP_CODE, "DAH");
+ request.addHeader(HEADER_PROJECT_CODE, "AXHUB");
+ request.addHeader(HEADER_USER_IP, "10.10.10.1");
+ request.addHeader(HEADER_CALLER_IP, "10.10.10.2");
+ request.addHeader(HEADER_CALLER_HOST, "agent-host-1");
+ request.addHeader(HEADER_CHANNEL, "SCRN");
+ request.addHeader(HEADER_AGENT_ID, "agent-public-1");
}
private McpExchangeFilter filter(McpProperties properties) {
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java
similarity index 92%
rename from src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java
rename to src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java
index 450b969..f1a9f4b 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java
+++ b/src/test/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidatorTest.java
@@ -1,10 +1,10 @@
-package io.shinhanlife.dap.biz.mcp.transport.http;
+package io.shinhanlife.dat.biz.mcp.transport.http;
-import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import io.shinhanlife.dap.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
+import io.shinhanlife.dat.biz.mcp.transport.http.McpProtocolVersionValidator.ProtocolVersionException;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;