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을 정리합니다. - * - *

처리 순서는 다음과 같습니다. - * - *

    - *
  1. 헤더에서 correlation 값을 뽑아 context를 만들고 응답 헤더에 먼저 심는다(오류 응답에도 실리도록) - *
  2. 본문을 크기 제한과 함께 읽어 다시 읽을 수 있는 wrapper로 감싼다 - *
  3. 로그·검증용으로 JSON-RPC method만 미리 확인한다 - *
  4. protocol version을 검증하고, 실패하면 controller까지 가지 않고 HTTP 400으로 끝낸다 - *
  5. 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;