From 6526e732f9fe461efab0171b0dfa60ac98c3b4bb Mon Sep 17 00:00:00 2001 From: janghw Date: Mon, 31 Aug 2026 14:37:12 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EB=B3=80=EA=B2=BD,=20MCP?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20retry=20=EC=A0=95=EC=B1=85=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../helm/mcp-server/templates/configmap.yaml | 4 +- .../helm/mcp-server/templates/deployment.yaml | 2 +- .../dat/biz/mcp/McpServerApplication.java | 23 ++- .../dat/biz/mcp/config/HttpClientConfig.java | 26 ++- .../dat/biz/mcp/config/McpProperties.java | 126 ++++++++++++-- .../biz/mcp/context/McpRequestContext.java | 26 +-- .../mcp/context/McpRequestContextHolder.java | 22 ++- .../mcp/execute/ToolArgumentValidator.java | 44 ++++- .../dat/biz/mcp/execute/ToolCall.java | 15 +- .../biz/mcp/execute/ToolExecutionService.java | 163 ++++++++++++++---- .../biz/mcp/execute/ToolRoutingService.java | 36 +++- .../dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java | 22 ++- .../dat/biz/mcp/jsonrpc/JsonRpcException.java | 33 +++- .../biz/mcp/jsonrpc/JsonRpcNotification.java | 22 ++- .../dat/biz/mcp/jsonrpc/JsonRpcRequest.java | 17 +- .../biz/mcp/jsonrpc/JsonRpcRequestParser.java | 20 ++- .../dat/biz/mcp/jsonrpc/JsonRpcResponse.java | 33 +++- .../dat/biz/mcp/method/InitializeHandler.java | 23 ++- .../InitializedNotificationHandler.java | 24 ++- .../mcp/method/McpMethodHandlerRegistry.java | 26 ++- .../dat/biz/mcp/method/ToolsCallHandler.java | 42 ++++- .../dat/biz/mcp/method/ToolsListHandler.java | 37 +++- .../ToolBundleStatusEndpoint.java | 19 +- .../ToolCatalogHealthIndicator.java | 21 ++- .../biz/mcp/observability/TraceLogger.java | 46 ++++- .../registry/LocalFileToolRegistryClient.java | 52 +++++- .../registry/PortalToolRegistryClient.java | 158 +++++++++++------ .../registry/RedisPortalRegistryCache.java | 30 +++- .../mcp/registry/RedisToolRegistryCache.java | 70 +++++--- .../biz/mcp/registry/ToolBundleDiscovery.java | 121 ++++++++++--- .../registry/ToolBundleRegistryClient.java | 32 +++- .../mcp/registry/ToolListChangedEvent.java | 26 ++- .../dat/biz/mcp/registry/ToolMetadata.java | 46 ++++- .../biz/mcp/registry/ToolRegistryClient.java | 37 ++-- .../ToolRegistryRefreshScheduler.java | 39 ++++- .../biz/mcp/registry/ToolRegistryService.java | 150 +++++++++++----- .../biz/mcp/toolclient/HttpToolClient.java | 88 +++++++--- .../dat/biz/mcp/toolclient/ToolClient.java | 84 +++++++-- .../http/CachedBodyHttpServletRequest.java | 72 ++++---- .../biz/mcp/transport/http/McpController.java | 32 +++- .../transport/http/McpExceptionHandler.java | 34 +++- .../mcp/transport/http/McpExchangeFilter.java | 93 +++++++--- .../http/McpProtocolVersionValidator.java | 25 ++- .../http/McpRequestContextFactory.java | 64 ++++--- .../transport/http/McpRouteKeyValidator.java | 21 ++- src/main/resources/application-dev.yml | 0 src/main/resources/application-ocp.yml | 13 -- src/main/resources/application-prod.yml | 0 src/main/resources/application-test.yml | 0 src/main/resources/application.yml | 14 +- .../resources/glow/application-glow-dev.yml | 0 .../resources/glow/application-glow-local.yml | 0 .../resources/glow/application-glow-prod.yml | 0 .../resources/glow/application-glow-test.yml | 0 src/main/resources/glow/application-glow.yml | 0 .../deploy/HelmDeploymentContractTest.java | 2 +- .../mcp/execute/ToolExecutionServiceTest.java | 79 +++++++++ .../mcp/execute/ToolRoutingServiceTest.java | 43 +++++ .../ToolBundleRegistryWiringTest.java | 2 +- 60 files changed, 1825 insertions(+), 476 deletions(-) create mode 100644 src/main/resources/application-dev.yml delete mode 100644 src/main/resources/application-ocp.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application-test.yml create mode 100644 src/main/resources/glow/application-glow-dev.yml create mode 100644 src/main/resources/glow/application-glow-local.yml create mode 100644 src/main/resources/glow/application-glow-prod.yml create mode 100644 src/main/resources/glow/application-glow-test.yml create mode 100644 src/main/resources/glow/application-glow.yml diff --git a/README.md b/README.md index 1cc7526..a044291 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Agent Builder는 공개 URL마다 별도 MCP로 등록하고 initialize한다. U | 환경 | Tool 원천 | Redis | |---|---|---| | `local` | local JSON fixture | 사용 안 함 | -| 운영(`ocp`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 | +| 운영(`prod`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 | 요청 경로의 `tools/list`와 `tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 모든 bundle에 사용 가능한 성공본이 있을 때만 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. 코드는 bundle N개 병합을 지원하지만 **운영 배포의 bundle은 항상 하나다**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). diff --git a/deploy/helm/mcp-server/templates/configmap.yaml b/deploy/helm/mcp-server/templates/configmap.yaml index 5c39141..be7b2bf 100644 --- a/deploy/helm/mcp-server/templates/configmap.yaml +++ b/deploy/helm/mcp-server/templates/configmap.yaml @@ -1,5 +1,5 @@ # 배포별로 달라지는 설정만 담는다. -# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고, +# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-prod.yml이 소유하고, # 이 파일이 같은 이름으로 덮어써 identity와 bundle만 배포 시점에 결정한다. {{- include "mcp-server.validate" . }} {{- $deployment := index .Values.deployments .Values.deploymentKey }} @@ -11,7 +11,7 @@ metadata: labels: {{- include "mcp-server.labels" . | nindent 4 }} data: - application-ocp.yml: | + application-prod.yml: | mcp: # "{배포 이름}-{global.env}"로 조립된다. 환경끼리 Redis key가 겹치지 않는다. identity: {{ include "mcp-server.identity" . }} diff --git a/deploy/helm/mcp-server/templates/deployment.yaml b/deploy/helm/mcp-server/templates/deployment.yaml index 1b9af6c..4bcd219 100644 --- a/deploy/helm/mcp-server/templates/deployment.yaml +++ b/deploy/helm/mcp-server/templates/deployment.yaml @@ -47,7 +47,7 @@ spec: containerPort: {{ .Values.ports.management }} env: - name: SPRING_PROFILES_ACTIVE - value: ocp + value: prod # ConfigMap을 jar 안의 설정보다 우선 적용한다. - name: SPRING_CONFIG_ADDITIONAL_LOCATION value: file:/opt/app/config/ diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java index b58938c..9deae55 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java @@ -9,9 +9,19 @@ import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableScheduling; /** - * AX HUB MCP Server의 Spring Boot 애플리케이션 시작점입니다. HTTP 요청을 직접 처리하지 않고 component scan, configuration properties, scheduler를 활성화해 - * transport·method·registry·execute·observability 구성요소를 조립합니다. 주요 의존성은 Spring Boot 자동 구성, {@code McpProperties} 설정 객체, MCP SDK의 JSON Schema 검증기이며 실행 인자는 Spring - * 컨테이너로 전달됩니다. + * @package io.shinhanlife.dat.biz.mcp + * @className McpServerApplication + * @description AX HUB MCP Server의 Spring Boot 애플리케이션 시작점입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @SpringBootApplication @ConfigurationPropertiesScan @@ -20,16 +30,17 @@ public class McpServerApplication { /** * Spring Boot 애플리케이션을 시작하는 최초 진입점입니다. 전달받은 실행 인자를 Spring에 넘기고 component scan과 설정 로딩을 시작합니다. + * + * @param args 애플리케이션 실행 인자입니다. */ public static void main(String[] args) { SpringApplication.run(McpServerApplication.class, args); } /** - * Tool inputSchema와 arguments를 JSON Schema 2020-12 기준으로 검증할 MCP SDK 검증기를 한 번 생성합니다. Tool 실행 전 검증 계층에서만 사용하며 MCP HTTP transport나 서버 lifecycle을 자동 구성하지 - * 않습니다. + * Tool inputSchema와 arguments를 JSON Schema 2020-12 기준으로 검증할 MCP SDK 검증기를 한 번 생성합니다. Tool 실행 전 검증 계층에서만 사용하며 MCP HTTP transport나 서버 lifecycle을 자동 구성하지 않습니다. * - * @return schema 컴파일 결과를 재사용하는 MCP SDK 검증기 + * @return 처리 결과를 반환합니다. */ @Bean JsonSchemaValidator mcpJsonSchemaValidator() { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java index 20f8eff..59e5b2b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/HttpClientConfig.java @@ -10,14 +10,28 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; /** - * Tool Service manifest 조회와 Tool 실행에 필요한 Spring/JDK client Bean을 구성하는 설정 클래스입니다. MCP 요청을 직접 처리하지 않으며 discovery와 {@code HttpToolClient}가 주입받을 연결·timeout 기본값을 - * 제공합니다. 주요 의존성은 {@link McpProperties}, Spring RestClient 및 JDK HttpClient입니다. + * @package io.shinhanlife.dat.biz.mcp.config + * @className HttpClientConfig + * @description Tool Service manifest 조회와 Tool 실행에 필요한 Spring/JDK client Bean을 구성하는 설정 클래스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Configuration public class HttpClientConfig { /** - * 여러 Tool 호출이 TCP 연결을 재사용할 수 있도록 공유 JDK HTTP client를 만듭니다. Tool별 read timeout은 이 객체를 새로 만들지 않고 HttpToolClient 쪽에서 적용합니다. + * Tool Service 실행 호출에 사용할 공유 JDK HTTP client를 생성합니다. + * + * @param properties MCP 설정 정보입니다. + * @return Tool Service 호출용 HTTP client를 반환합니다. */ @Bean @Qualifier("toolHttpClient") @@ -28,8 +42,10 @@ public class HttpClientConfig { } /** - * Tool Service bundle 매니페스트 조회 전용 RestClient를 생성합니다. 조회 대상이 bundle마다 다르므로 base URL을 두지 않고 매 호출에서 전체 {@code manifestUrl}을 사용합니다. timeout은 Tool 실행보다 짧게 잡아, - * 느린 bundle 하나가 전체 조회 주기를 잡아먹지 않게 합니다. + * Portal Registry와 Tool Service manifest 조회에 사용할 RestClient를 생성합니다. + * + * @param properties MCP 설정 정보입니다. + * @return manifest 조회용 RestClient를 반환합니다. */ @Bean @Qualifier("manifestRestClient") diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java index 78b10c8..651bba1 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java @@ -13,8 +13,19 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; /** - * {@code application.yml}의 {@code mcp.*} 설정을 타입 안전한 불변 객체로 묶고 시작 시 유효성을 검증하는 구성 계약입니다. MCP 요청을 직접 처리하지 않으며 공개 endpoint, HTTP transport, Registry, Tool client와 - * observability 구성요소가 각자의 설정만 읽습니다. 주요 의존성은 Spring Boot ConfigurationProperties와 Jakarta Validation이며, 중첩 record는 서버·연동·cache·trace 정책을 분리합니다. + * @package io.shinhanlife.dat.biz.mcp.config + * @className McpProperties + * @description {@code application.yml}의 {@code mcp.*} 설정을 타입 안전한 불변 객체로 묶고 시작 시 유효성을 검증하는 구성 계약입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Validated @ConfigurationProperties(prefix = "mcp") @@ -36,15 +47,18 @@ public record McpProperties( List<@Valid Bundle> bundles) { /** - * 선언되지 않은 bundle 목록을 빈 목록으로 정규화해 이후 코드가 null을 검사하지 않게 합니다. + * 선택 설정이 생략된 테스트·로컬 구성에서도 MCP가 최소 기본값으로 기동할 수 있게 보정합니다. 외부 원천 주소는 임의 생성하지 않고, Tool 호출 timeout과 retry처럼 안전한 내부 기본값만 채웁니다. */ public McpProperties { bundles = bundles == null ? List.of() : List.copyOf(bundles); + toolClient = toolClient == null ? ToolClient.defaults() : toolClient; portal = portal == null ? new Portal(false, "", "", 300) : portal; } /** * 조회 대상으로 켜져 있는 bundle만 골라 반환합니다. 조회·병합·Actuator 상태가 같은 목록을 사용합니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List enabledBundles() { return bundles.stream().filter(Bundle::enabled).toList(); @@ -52,6 +66,8 @@ public record McpProperties( /** * bundle 조회를 켰는데 조회 대상이 하나도 없으면 기동에 실패시킵니다. 이 상태로 기동하면 {@code tools/list}가 영구히 비어 있으므로 운영 중 발견하는 것보다 기동 실패가 낫습니다. + * + * @return 처리 결과를 반환합니다. */ @AssertTrue(message = "mcp.discovery.enabled=true requires at least one entry in mcp.bundles") public boolean isDiscoveryTargetDeclared() { @@ -59,8 +75,9 @@ public record McpProperties( } /** - * Portal Registry를 사용할 때 조회 URL이 선언되었는지 검증합니다. - * route key는 에이전트가 호출한 {@code /mcp/{routeKey}} 경로에서 동적으로 결정되므로 설정 기본값으로 보정하지 않습니다. + * Portal Registry를 사용할 때 조회 URL이 선언되었는지 검증합니다. route key는 에이전트가 호출한 {@code /mcp/{routeKey}} 경로에서 동적으로 결정되므로 설정 기본값으로 보정하지 않습니다. + * + * @return 처리 결과를 반환합니다. */ @AssertTrue(message = "mcp.portal.enabled=true requires mcp.portal.registry-url") public boolean isPortalTargetDeclared() { @@ -68,8 +85,9 @@ public record McpProperties( } /** - * bundle {@code id}와 {@code namePrefix}가 서로 충돌하지 않는지 기동 시 검증합니다. prefix가 다른 prefix의 접두사이면(예: {@code a.}와 {@code a.b.}) Tool 이름이 어느 bundle 소속인지 확정되지 않아 라우팅 - * 대상이 흔들리므로 이 조합을 금지합니다. + * bundle {@code id}와 {@code namePrefix}가 서로 충돌하지 않는지 기동 시 검증합니다. prefix가 다른 prefix의 접두사이면(예: {@code a.}와 {@code a.b.}) Tool 이름이 어느 bundle 소속인지 확정되지 않아 라우팅 대상이 흔들리므로 이 조합을 금지합니다. + * + * @return 처리 결과를 반환합니다. */ @AssertTrue( message = @@ -99,6 +117,9 @@ public record McpProperties( /** * 공백이 아닌 문자열인지 확인해 Portal Registry 설정 검증에 사용합니다. + * + * @param value 처리할 값입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean hasText(String value) { return value != null && !value.isBlank(); @@ -120,14 +141,92 @@ public record McpProperties( } /** - * Tool Service 호출의 timeout과 header 전달 정책 설정입니다. + * Tool Service 호출의 timeout, header 전달, 제한적 retry 정책 설정입니다. */ public record ToolClient( @Min(1) int connectTimeoutMillis, @Min(1) int readTimeoutMillis, @Min(1) long requestDeadlineMillis, boolean forwardAuthorization, - @NotBlank String apiKey) { + @NotBlank String apiKey, + @Valid Retry retry) { + + /** + * 테스트나 최소 설정에서 사용할 Tool client 기본 timeout과 retry 정책을 반환합니다. + * + * @return Tool client 기본 설정을 반환합니다. + */ + public static ToolClient defaults() { + return new ToolClient(1_000, 5_000, 270_000, false, "tool-server-key", Retry.defaults()); + } + + /** + * 기존 테스트와 설정 생성 코드가 retry 값을 생략해도 기본 정책을 사용하도록 보정합니다. + * + * @param connectTimeoutMillis 연결 timeout 밀리초입니다. + * @param readTimeoutMillis 읽기 timeout 밀리초입니다. + * @param requestDeadlineMillis MCP 요청 전체 deadline 밀리초입니다. + * @param forwardAuthorization Authorization header 전달 여부입니다. + * @param apiKey Tool Service API key입니다. + */ + public ToolClient( + int connectTimeoutMillis, + int readTimeoutMillis, + long requestDeadlineMillis, + boolean forwardAuthorization, + String apiKey) { + this( + connectTimeoutMillis, + readTimeoutMillis, + requestDeadlineMillis, + forwardAuthorization, + apiKey, + Retry.defaults()); + } + + /** + * retry 설정이 생략된 환경에서도 MCP 기본 retry 정책을 적용해 null 설정으로 인한 기동 실패를 막습니다. + */ + public ToolClient { + retry = retry == null ? Retry.defaults() : retry; + } + } + + /** + * MCP가 Tool Service 호출 실패를 제한적으로 재시도할 때 사용하는 기본 retry 설정입니다. + */ + public record Retry( + boolean enabled, + @Min(1) int maxAttempts, + @Min(0) long backoffMillis, + @NotEmpty List retryOnHttpStatus) { + + /** + * retry 대상 HTTP 상태 목록을 불변 목록으로 보정하고, 설정이 생략되면 MCP 기본 일시 장애 상태 목록을 사용합니다. + */ + public Retry { + retryOnHttpStatus = retryOnHttpStatus == null + ? defaultRetryOnHttpStatus() + : List.copyOf(retryOnHttpStatus); + } + + /** + * manifest가 별도 retry 정책을 제공하지 않을 때 사용할 MCP 기본 정책을 반환합니다. + * + * @return MCP 기본 retry 정책을 반환합니다. + */ + public static Retry defaults() { + return new Retry(true, 2, 200, defaultRetryOnHttpStatus()); + } + + /** + * 일시 장애로 볼 수 있는 HTTP 상태 목록을 반환합니다. + * + * @return retry 대상 HTTP status 목록을 반환합니다. + */ + private static List defaultRetryOnHttpStatus() { + return List.of(408, 429, 500, 502, 503, 504); + } } /** @@ -136,9 +235,7 @@ public record McpProperties( public record Redis(boolean enabled, @NotBlank String keyPrefix, String portalRegistryKey) { /** - * Redis cache key를 정규화합니다. Tool snapshot key prefix는 기존 규칙을 유지하고, - * Portal registry fallback key는 포털이 쓰는 값을 외부 설정으로 주입받되 비어 있으면 - * MCP identity와 같은 namespace 아래 기본 key를 사용합니다. + * Redis Portal registry key가 명시되지 않으면 MCP cache namespace 아래의 기본 key를 사용합니다. */ public Redis { if (portalRegistryKey == null || portalRegistryKey.isBlank()) { @@ -192,15 +289,14 @@ public record McpProperties( public record Protocol( @NotEmpty List<@NotBlank String> supportedVersions, @NotBlank String preferredVersion) { - /** - * 외부에서 받은 지원 버전 목록을 복사해 설정 객체가 생성된 뒤 바뀌지 않게 합니다. - */ public Protocol { supportedVersions = List.copyOf(supportedVersions); } /** * preferred version이 실제 지원 목록에도 포함되는지 설정 로딩 시 검증합니다. + * + * @return 처리 결과를 반환합니다. */ @AssertTrue(message = "preferredVersion must be included in supportedVersions") public boolean isPreferredVersionSupported() { 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 index 894f93b..66ae519 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContext.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContext.java @@ -1,13 +1,22 @@ 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 호출 경계까지 전달하는 역할만 합니다. + * @package io.shinhanlife.dat.biz.mcp.context + * @className McpRequestContext + * @description 하나의 MCP HTTP 요청 전체에서 공유할 표준 헤더, correlation, route, deadline 정보를 담는 불변 context입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public record McpRequestContext( String routeKey, @@ -27,17 +36,14 @@ public record McpRequestContext( String authorization, Instant deadline) { - /** - * deadline이 없는 context를 허용하되 이미 만료된 것으로 취급합니다. - * 정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우며, 잘못 만들어진 context가 흘러들어오면 Tool 호출이 즉시 timeout으로 실패하도록 보수적으로 정규화합니다. - */ public McpRequestContext { deadline = deadline == null ? Instant.now() : deadline; } /** - * 이 요청에 남은 시간을 밀리초로 알려 줍니다. - * Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. + * 이 요청에 남은 시간을 밀리초로 알려 줍니다. Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. + * + * @return 계산된 숫자 값을 반환합니다. */ public long remainingMillis() { return Duration.between(Instant.now(), deadline).toMillis(); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java index b619689..3baf03e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/context/McpRequestContextHolder.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.context; import java.util.Optional; /** - * 현재 요청 처리 thread에 {@link McpRequestContext}를 임시로 연결하는 ThreadLocal holder입니다. {@code McpExchangeFilter}가 설정하고 정상·예외 완료 시 제거합니다. 요청 밖에서 context를 보관하거나 서버 세션 상태로 - * 사용하면 안 되는 correlation 전용 유틸리티입니다. + * @package io.shinhanlife.dat.biz.mcp.context + * @className McpRequestContextHolder + * @description 현재 요청 처리 thread에 {@link McpRequestContext}를 임시로 연결하는 ThreadLocal holder입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public final class McpRequestContextHolder { @@ -12,12 +23,15 @@ public final class McpRequestContextHolder { /** * 인스턴스를 만들 수 없는 정적 유틸리티 클래스임을 명확히 합니다. + * */ private McpRequestContextHolder() { } /** * 현재 요청을 처리하는 thread에 request context를 저장합니다. + * + * @param context 현재 MCP 요청 context입니다. */ public static void set(McpRequestContext context) { CONTEXT.set(context); @@ -25,6 +39,8 @@ public final class McpRequestContextHolder { /** * 현재 thread의 request context를 Optional로 안전하게 조회합니다. + * + * @return 조회된 선택값을 반환합니다. */ public static Optional get() { return Optional.ofNullable(CONTEXT.get()); @@ -32,6 +48,8 @@ public final class McpRequestContextHolder { /** * 반드시 context가 있어야 하는 처리 단계에서 값을 반환합니다. filter 밖에서 잘못 호출하면 즉시 예외를 발생시켜 잘못된 correlation 처리를 막습니다. + * + * @return 처리 결과를 반환합니다. */ public static McpRequestContext require() { return get() diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java index f01c698..4d8b75d 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolArgumentValidator.java @@ -10,9 +10,19 @@ import java.util.Map; import org.springframework.stereotype.Component; /** - * Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 형식 위반은 upstream Tool Service 호출 전에 Invalid - * params 오류로 끝냅니다. 주요 의존성은 JSON 변환과 크기 계산용 {@link ObjectMapper}, MCP SDK {@link JsonSchemaValidator}, 실행 정책 원천인 {@link ToolMetadata}, 호출 정보인 - * {@link ToolCall}입니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolArgumentValidator + * @description Registry metadata에 정의된 MCP SDK JSON Schema 2020-12 규칙으로 Tool arguments를 검증합니다. Registry 기반 실행 계획을 만들 때 호출되며, 형식 위반은 upstream Tool Service 호출 전에 Invalid params 오류로 끝냅니다. 주요 의존성은 JSON 변환과 크기 계산용 {@link ObjectMapper}, MCP SDK {@link JsonSchemaValidator}, 실행 정책 원천인 {@link ToolMetadata}, 호출 정보인 {@link ToolCall}입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class ToolArgumentValidator { @@ -22,6 +32,9 @@ public class ToolArgumentValidator { /** * JSON 변환용 Jackson mapper와 MCP SDK JSON Schema 검증기를 주입받습니다. 검증기는 Spring singleton으로 생성되어 동일한 Tool schema 컴파일 결과를 재사용합니다. + * + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param jsonSchemaValidator 협력 객체입니다. */ public ToolArgumentValidator(ObjectMapper objectMapper, JsonSchemaValidator jsonSchemaValidator) { this.objectMapper = objectMapper; @@ -29,16 +42,20 @@ public class ToolArgumentValidator { } /** - * Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반 - * 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다. + * Registry에서 찾은 Tool의 {@code inputSchema}를 호출 arguments에 적용합니다. {@link ToolExecutionService}가 HTTP routing 전에 호출하므로 실패하면 Tool Service에는 요청이 전송되지 않으며, 위반 내용은 기존 외부 계약인 {@code -32602 Invalid params}로 변환됩니다. + * + * @param call Tool 처리 정보입니다. + * @param metadata Tool 처리 정보입니다. */ public void validate(ToolCall call, ToolMetadata metadata) { validateInputSchema(call, metadata.inputSchema()); } /** - * Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. 기존 외부 계약을 보존하기 위해 검증 실패는 SDK의 Tool result가 아니라 최상위 Invalid params 예외로 변환합니다. - * SDK 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다. + * Registry inputSchema를 MCP SDK 검증기에 전달해 JSON Schema 2020-12 keyword를 검사합니다. 기존 외부 계약을 보존하기 위해 검증 실패는 SDK의 Tool result가 아니라 최상위 Invalid params 예외로 변환합니다. SDK 원문 오류는 입력값을 포함할 수 있으므로 외부에는 고정된 안전 메시지만 제공합니다. + * + * @param call Tool 처리 정보입니다. + * @param schema 입력값입니다. */ private void validateInputSchema(ToolCall call, JsonNode schema) { if (schema == null || schema.isNull()) { @@ -56,8 +73,10 @@ public class ToolArgumentValidator { } /** - * 기존 Agent Builder 계약에 공개된 object·required·기본 type 오류 문구를 SDK 검증 전에 유지합니다. 이 범위 밖의 minLength, pattern, additionalProperties 같은 keyword는 이어지는 SDK 검증기가 - * 담당합니다. + * 기존 Agent Builder 계약에 공개된 object·required·기본 type 오류 문구를 SDK 검증 전에 유지합니다. 이 범위 밖의 minLength, pattern, additionalProperties 같은 keyword는 이어지는 SDK 검증기가 담당합니다. + * + * @param call Tool 처리 정보입니다. + * @param schema 입력값입니다. */ private void validateStableContract(ToolCall call, JsonNode schema) { if (schema.has("type") && !"object".equals(schema.path("type").asText())) { @@ -90,6 +109,10 @@ public class ToolArgumentValidator { /** * 기존 기본 JSON 타입 오류 문구를 보존하면서 각 arguments 값의 선언 타입을 검사합니다. + * + * @param field 처리할 값입니다. + * @param type 입력값입니다. + * @param value 처리할 값입니다. */ private void validateStableType(String field, String type, JsonNode value) { if (type == null) { @@ -112,6 +135,9 @@ public class ToolArgumentValidator { /** * 검증 실패 이유를 Invalid params JSON-RPC 예외로 통일합니다. + * + * @param details 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException invalid(String details) { return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java index 4310dd9..72b5935 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolCall.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.execute; import com.fasterxml.jackson.databind.JsonNode; /** - * MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. HTTP 요청을 직접 처리하지 않으며 tools/call handler가 만들고 Tool 실행 계층이 소비합니다. {@code arguments}는 원본 JSON - * 구조를 보존해 이후 schema 검증과 Tool Service 호출에 사용합니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolCall + * @description MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public record ToolCall(String toolName, JsonNode arguments) { } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java index 472bbb8..019a784 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionService.java @@ -17,8 +17,19 @@ import java.util.concurrent.ConcurrentMap; import org.springframework.stereotype.Service; /** - * MCP Tool 실행의 orchestration 서비스입니다. {@code tools/call}의 단일 Tool 실행 단계를 만들고 routing된 HTTP 요청을 실행하며, timeout·권한·실패를 JSON-RPC 내부 오류로 정규화합니다. 주요 의존성은 Registry, - * argument validator, routing service, {@link ToolClient}와 Tool HTTP 호출·응답 경계를 기록하는 trace logger입니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolExecutionService + * @description MCP Tool 실행의 orchestration 서비스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Service public class ToolExecutionService { @@ -34,6 +45,12 @@ public class ToolExecutionService { /** * metadata 조회, 입력 검증, HTTP routing, Tool client와 경계 로그 협력 객체를 주입받습니다. + * + * @param registryService Tool metadata 조회 서비스입니다. + * @param argumentValidator Tool arguments 검증기입니다. + * @param routingService Tool 호출 요청 변환 서비스입니다. + * @param toolClient Tool Service 호출 client입니다. + * @param traceLogger 경계 이벤트 로그 기록기입니다. */ public ToolExecutionService( ToolRegistryService registryService, @@ -49,43 +66,77 @@ public class ToolExecutionService { } /** - * Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 한 번 실행합니다. 처리 순서는 Registry 조회 → inputSchema 검증 → endpoint/timeout 확정 → ToolClient 호출이며, 호출 전후에는 - * payload를 제외한 Tool 이름·버전·상태·소요 시간만 기록합니다. ToolClient 실패는 실행 종류별 {@link JsonRpcException}으로 바꾸고 최종 {@code isError} 변환은 handler에 맡깁니다. + * Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 실행합니다. retry가 허용된 조회성 또는 멱등 Tool에서 일시 장애가 발생하면 설정된 횟수 안에서 재시도하고, 404/410은 retry하지 않고 stale snapshot 보정 refresh만 수행합니다. + * + * @param call Tool 호출 요청입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Tool Service 응답 본문과 총 실행 시간을 반환합니다. */ public Result execute(ToolCall call, McpRequestContext context) { ToolMetadata metadata = registryService.findEnabledTool(context.routeKey(), call.toolName()); argumentValidator.validate(call, metadata); ToolRequest toolRequest = routingService.route(call, metadata); - traceLogger.event( - "tool_http_request_started", - "toolName", - toolRequest.toolName(), - "version", - toolRequest.version()); long started = System.nanoTime(); - try { - ToolResponse response = toolClient.execute(toolRequest, context); - double duration = elapsedMillis(started); + int attempt = 1; + while (true) { traceLogger.event( - "tool_http_response_received", + "tool_http_request_started", "toolName", toolRequest.toolName(), - "statusCode", - response.statusCode(), - "durationMillis", - duration); - return new Result(response.data(), duration); - } catch (ToolClientException exception) { - traceLogger.error("tool_http_request_failed", exception, "toolName", toolRequest.toolName()); - refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception); - throw mapException(exception, toolRequest); + "version", + toolRequest.version(), + "attempt", + attempt, + "maxAttempts", + toolRequest.maxAttempts()); + try { + ToolResponse response = toolClient.execute(toolRequest, context); + double duration = elapsedMillis(started); + traceLogger.event( + "tool_http_response_received", + "toolName", + toolRequest.toolName(), + "statusCode", + response.statusCode(), + "durationMillis", + duration, + "attempt", + attempt); + return new Result(response.data(), duration); + } catch (ToolClientException exception) { + traceLogger.error( + "tool_http_request_failed", + exception, + "toolName", + toolRequest.toolName(), + "attempt", + attempt); + refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception); + if (!shouldRetry(toolRequest, exception, attempt)) { + throw mapException(exception, toolRequest); + } + traceLogger.event( + "tool_http_request_retrying", + "toolName", + toolRequest.toolName(), + "attempt", + attempt + 1, + "backoffMillis", + toolRequest.backoffMillis()); + if (!sleepBeforeRetry(toolRequest.backoffMillis())) { + throw mapException(exception, toolRequest); + } + attempt++; + } } } /** - * Tool Service가 404/410을 반환하면 현재 route의 in-memory snapshot이 오래되었을 수 있으므로 즉시 registry refresh를 시도합니다. - * 현재 tools/call 결과는 원래 upstream 실패로 유지하고, refresh 실패는 로그로만 남겨 기존 정상 snapshot을 비우지 않습니다. - * route별 cooldown을 둬 삭제된 Tool을 여러 Agent가 동시에 호출할 때 manifest 호출이 폭증하지 않게 합니다. + * Tool Service가 404/410을 반환하면 현재 route의 in-memory snapshot이 오래되었을 수 있으므로 즉시 registry refresh를 시도합니다. 현재 tools/call 결과는 원래 upstream 실패로 유지하고, refresh 실패는 로그로만 남겨 기존 정상 snapshot을 비우지 않습니다. route별 cooldown을 둬 삭제된 Tool을 여러 Agent가 동시에 호출할 때 manifest 호출이 폭증하지 않게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param request 처리할 요청 정보입니다. + * @param exception 처리 중 발생한 예외 정보입니다. */ private void refreshRouteOnStaleToolSignal(String routeKey, ToolRequest request, ToolClientException exception) { if (!isStaleToolSignal(exception) || !claimStaleRefreshSlot(routeKey)) { @@ -111,8 +162,49 @@ public class ToolExecutionService { } /** - * upstream HTTP 상태가 삭제되었거나 더 이상 제공되지 않는 Tool을 의미하는지 판단합니다. - * 404와 410만 stale snapshot 보정 신호로 취급하고, 인증·권한·서버 오류는 기존 실행 실패로만 처리합니다. + * 현재 실패가 retry 가능한 일시 장애인지 판단합니다. 404/410은 stale snapshot 보정 대상이므로 retry에서 제외하고, timeout·network·설정된 HTTP 상태만 retry 대상으로 봅니다. + * + * @param request Tool Service 호출 요청입니다. + * @param exception 처리 중 발생한 예외 정보입니다. + * @param attempt 현재 시도 번호입니다. + * @return 재시도 여부를 반환합니다. + */ + private boolean shouldRetry(ToolRequest request, ToolClientException exception, int attempt) { + if (!request.retryable() || attempt >= request.maxAttempts() || isStaleToolSignal(exception)) { + return false; + } + if (exception.kind() == ToolClientException.Kind.TIMEOUT + || exception.kind() == ToolClientException.Kind.NETWORK) { + return true; + } + return exception.httpStatusCode().isPresent() + && request.retryOnHttpStatus().contains(exception.httpStatusCode().getAsInt()); + } + + /** + * retry backoff 시간만큼 대기합니다. 요청 thread가 interrupt되면 interrupt 상태를 복구하고 더 이상 재시도하지 않게 false를 반환합니다. + * + * @param backoffMillis 재시도 전 대기 시간입니다. + * @return 다음 retry를 계속해도 되는지 반환합니다. + */ + private boolean sleepBeforeRetry(long backoffMillis) { + if (backoffMillis <= 0) { + return true; + } + try { + Thread.sleep(backoffMillis); + return true; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** + * upstream HTTP 상태가 삭제되었거나 더 이상 제공되지 않는 Tool을 의미하는지 판단합니다. 404와 410만 stale snapshot 보정 신호로 취급하고, 인증·권한·서버 오류는 기존 실행 실패로만 처리합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isStaleToolSignal(ToolClientException exception) { java.util.OptionalInt status = exception.httpStatusCode(); @@ -120,8 +212,10 @@ public class ToolExecutionService { } /** - * 같은 route에 대한 stale refresh가 짧은 시간 안에 반복되지 않도록 best-effort로 slot을 확보합니다. - * 동시 요청에서는 먼저 들어온 한 요청만 refresh를 수행하고 나머지는 기존 실패 응답만 반환합니다. + * 같은 route에 대한 stale refresh가 짧은 시간 안에 반복되지 않도록 best-effort로 slot을 확보합니다. 동시 요청에서는 먼저 들어온 한 요청만 refresh를 수행하고 나머지는 기존 실패 응답만 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean claimStaleRefreshSlot(String routeKey) { String key = routeKey == null ? "" : routeKey; @@ -136,6 +230,9 @@ public class ToolExecutionService { /** * System.nanoTime 기준 경과 시간을 밀리초 단위로 계산합니다. + * + * @param startedNanos 계산 또는 제한에 사용할 숫자 값입니다. + * @return 계산된 숫자 값을 반환합니다. */ private double elapsedMillis(long startedNanos) { return (System.nanoTime() - startedNanos) / 1_000_000.0d; @@ -143,6 +240,10 @@ public class ToolExecutionService { /** * Tool client 실패 종류를 timeout·권한·실행 JSON-RPC 코드로 일관되게 변환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @param request 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException mapException(ToolClientException exception, ToolRequest request) { JsonRpcErrorCode code = @@ -150,7 +251,7 @@ public class ToolExecutionService { case TIMEOUT -> JsonRpcErrorCode.TOOL_TIMEOUT; case UNAUTHORIZED -> JsonRpcErrorCode.UNAUTHORIZED; case FORBIDDEN -> JsonRpcErrorCode.FORBIDDEN; - case EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR; + case NETWORK, EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR; }; return new JsonRpcException( code, diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java index 35a6dc9..b18abdb 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingService.java @@ -1,6 +1,7 @@ package io.shinhanlife.dat.biz.mcp.execute; import io.shinhanlife.dat.biz.mcp.config.McpProperties; +import io.shinhanlife.dat.biz.mcp.config.McpProperties.Retry; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dat.biz.mcp.registry.ToolMetadata; @@ -12,8 +13,19 @@ import java.util.regex.Pattern; import org.springframework.stereotype.Service; /** - * Registry에서 확정된 Tool metadata를 실제 {@link ToolClient} 호출용 HTTP 요청으로 변환하는 routing 서비스입니다. 확정된 Tool metadata에 대해서만 동작하며, AgentBuilder 대신 Tool을 선택하거나 업무 규칙을 판단하지 - * 않습니다. 주요 의존성은 {@link ToolCall}, {@link ToolMetadata}와 {@link io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest} 계약입니다. + * @package io.shinhanlife.dat.biz.mcp.execute + * @className ToolRoutingService + * @description Registry에서 확정된 Tool metadata를 실제 {@link io.shinhanlife.dat.biz.mcp.toolclient.ToolClient} 호출용 HTTP 요청으로 변환하는 routing 서비스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Service public class ToolRoutingService { @@ -23,14 +35,19 @@ public class ToolRoutingService { /** * Tool별 timeout이 없을 때 사용할 공통 Tool client 설정을 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public ToolRoutingService(McpProperties properties) { this.properties = properties; } /** - * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. exact endpoint metadata는 그대로 사용하고, legacy base endpoint metadata만 Tool 이름을 - * path segment로 덧붙입니다. 확정된 endpoint가 절대 HTTP(S)가 아니면 JSON-RPC Tool 실행 오류로 변환합니다. + * 검증된 호출과 metadata를 실제 HTTP 호출에 사용할 ToolRequest로 변환합니다. endpoint와 timeout뿐 아니라 MCP 기본 retry 설정과 Tool annotations를 조합해 이 호출이 재시도 가능한지도 확정합니다. + * + * @param call Tool 호출 요청입니다. + * @param metadata Tool metadata입니다. + * @return Tool Service 호출 요청을 반환합니다. */ public ToolRequest route(ToolCall call, ToolMetadata metadata) { String endpoint = metadata.endpoint(); @@ -43,16 +60,25 @@ public class ToolRoutingService { if (!metadata.exactEndpoint()) { endpoint = endpoint.replaceAll("/+$", "") + "/" + metadata.name(); } + Retry retry = properties.toolClient().retry(); + boolean retryable = retry.enabled() && metadata.retrySafeByAnnotation() && retry.maxAttempts() > 1; return new ToolRequest( metadata.name(), metadata.version(), endpoint, call.arguments().deepCopy(), - metadata.effectiveTimeoutMillis(properties.toolClient().readTimeoutMillis())); + metadata.effectiveTimeoutMillis(properties.toolClient().readTimeoutMillis()), + retry.retryOnHttpStatus(), + retry.maxAttempts(), + retry.backoffMillis(), + retryable); } /** * Tool endpoint가 절대 HTTP(S) URL인지 검사해 내부망 상대 경로나 다른 scheme 호출을 막습니다. + * + * @param endpoint 검증할 Tool endpoint입니다. + * @param toolName 대상 Tool 이름입니다. */ private void validateEndpoint(String endpoint, String toolName) { try { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java index 6e052da..55b19a8 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcErrorCode.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.jsonrpc; import io.modelcontextprotocol.spec.McpSchema; /** - * 이 서버가 JSON-RPC error envelope에 사용할 표준 및 서버 내부 확장 오류 코드를 정의합니다. 요청을 직접 처리하지 않으며 validator, registry, 실행 계층이 발생시킨 오류를 exception handler와 error factory가 일관된 - * 숫자·메시지로 직렬화하도록 하는 공통 계약입니다. 표준 JSON-RPC 숫자는 MCP SDK 상수를 사용하고, Tool 실행·Registry·권한 오류만 이 서버의 확장 범위로 유지합니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcErrorCode + * @description 이 서버가 JSON-RPC error envelope에 사용할 표준 및 서버 내부 확장 오류 코드를 정의합니다. 요청을 직접 처리하지 않으며 validator, registry, 실행 계층이 발생시킨 오류를 exception handler와 error factory가 일관된 숫자·메시지로 직렬화하도록 하는 공통 계약입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public enum JsonRpcErrorCode { PARSE_ERROR(McpSchema.ErrorCodes.PARSE_ERROR, "Parse error"), @@ -22,9 +33,6 @@ public enum JsonRpcErrorCode { private final int code; private final String message; - /** - * 숫자 오류 코드와 외부에 표시할 표준 메시지를 한 쌍으로 저장합니다. - */ JsonRpcErrorCode(int code, String message) { this.code = code; this.message = message; @@ -32,6 +40,8 @@ public enum JsonRpcErrorCode { /** * JSON-RPC error 객체에 기록할 숫자 코드를 반환합니다. + * + * @return 계산된 숫자 값을 반환합니다. */ public int code() { return code; @@ -39,6 +49,8 @@ public enum JsonRpcErrorCode { /** * JSON-RPC error 객체에 기록할 안전한 기본 메시지를 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ public String message() { return message; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java index 68c5850..eff85be 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcException.java @@ -3,8 +3,19 @@ package io.shinhanlife.dat.biz.mcp.jsonrpc; import com.fasterxml.jackson.databind.JsonNode; /** - * 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. transport, registry, execute 계층이 이 예외를 발생시키고, {@code McpController} 또는 - * {@code McpExceptionHandler}가 JSON-RPC error 응답으로 변환합니다. 주요 의존성은 {@link JsonRpcErrorCode}와 응답 correlation을 위한 JSON 요청 ID이며, HTTP 응답을 직접 만들지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcException + * @description 처리 계층에서 JSON-RPC 오류 코드·안전한 상세 정보·원 요청 ID를 함께 전달하기 위한 런타임 예외입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public class JsonRpcException extends RuntimeException { @@ -14,6 +25,9 @@ public class JsonRpcException extends RuntimeException { /** * 오류 코드와 간단한 상세 설명만으로 JSON-RPC 예외를 만듭니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @param details 처리할 값입니다. */ public JsonRpcException(JsonRpcErrorCode errorCode, String details) { this(errorCode, details, null, null); @@ -21,6 +35,10 @@ public class JsonRpcException extends RuntimeException { /** * 원인 예외를 함께 보존해야 할 때 사용하는 생성자입니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @param details 처리할 값입니다. + * @param cause 처리 중 발생한 예외 정보입니다. */ public JsonRpcException(JsonRpcErrorCode errorCode, String details, Throwable cause) { this(errorCode, details, null, cause); @@ -28,6 +46,11 @@ public class JsonRpcException extends RuntimeException { /** * 오류 코드, 응답 data, 원 요청 ID, 원인 예외를 모두 지정합니다. requestId를 보존하면 실패 응답도 어떤 JSON-RPC 요청에서 발생했는지 연결할 수 있습니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @param errorData 처리 중 발생한 예외 정보입니다. + * @param requestId 처리할 요청 정보입니다. + * @param cause 처리 중 발생한 예외 정보입니다. */ public JsonRpcException( JsonRpcErrorCode errorCode, Object errorData, JsonNode requestId, Throwable cause) { @@ -39,6 +62,8 @@ public class JsonRpcException extends RuntimeException { /** * 표준 JSON-RPC 오류 종류를 반환합니다. + * + * @return 처리 결과를 반환합니다. */ public JsonRpcErrorCode errorCode() { return errorCode; @@ -46,6 +71,8 @@ public class JsonRpcException extends RuntimeException { /** * 오류 응답의 data 영역에 넣을 안전한 상세 정보를 반환합니다. + * + * @return 처리 결과를 반환합니다. */ public Object errorData() { return errorData; @@ -53,6 +80,8 @@ public class JsonRpcException extends RuntimeException { /** * 실패한 원 요청의 JSON-RPC id를 반환합니다. + * + * @return 처리된 JSON 값을 반환합니다. */ public JsonNode requestId() { return requestId; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java index 71500fa..c5b5be5 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcNotification.java @@ -4,10 +4,19 @@ import com.fasterxml.jackson.annotation.JsonInclude; import io.modelcontextprotocol.spec.McpSchema; /** - * MCP Server가 Agent Builder로 비동기 알림을 보낼 때 사용할 JSON-RPC 2.0 notification envelope입니다. - * 일반 request handler가 즉시 HTTP 응답으로 반환하는 객체가 아니라 Registry refresh 같은 배경 처리 단계에서 생성되며, - * 실제 전송은 SSE/Streamable HTTP 같은 transport 확장 지점이 담당합니다. 주요 의존성은 JSON-RPC version 상수와 - * notification method 계약입니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcNotification + * @description MCP Server가 Agent Builder로 비동기 알림을 보낼 때 사용할 JSON-RPC 2.0 notification envelope입니다. + * @author j.h.w + * @create 2026.08.11 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.11   j.h.w         최초생성
+ *
+ * 
*/ @JsonInclude(JsonInclude.Include.NON_NULL) public record JsonRpcNotification(String jsonrpc, String method, Object params) { @@ -15,8 +24,9 @@ public record JsonRpcNotification(String jsonrpc, String method, Object params) public static final String METHOD_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; /** - * Tool catalog snapshot 변경을 Agent Builder에 알리는 표준 MCP notification을 생성합니다. - * notification은 응답 id가 없으며, 최신 목록은 Agent Builder가 이후 {@code tools/list}를 다시 호출해 가져갑니다. + * Tool catalog snapshot 변경을 Agent Builder에 알리는 표준 MCP notification을 생성합니다. notification은 응답 id가 없으며, 최신 목록은 Agent Builder가 이후 {@code tools/list}를 다시 호출해 가져갑니다. + * + * @return 처리 결과를 반환합니다. */ public static JsonRpcNotification toolsListChanged() { return new JsonRpcNotification(McpSchema.JSONRPC_VERSION, METHOD_TOOLS_LIST_CHANGED, null); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java index 4f92279..cd9b96b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequest.java @@ -3,13 +3,26 @@ package io.shinhanlife.dat.biz.mcp.jsonrpc; import com.fasterxml.jackson.databind.JsonNode; /** - * 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. {@link JsonRpcRequestParser}가 만들고 controller와 method handler가 사용하며, {@code id} 유무로 notification 여부를 판단합니다. HTTP 헤더나 인증 - * 정보는 포함하지 않고 request context가 별도로 관리합니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcRequest + * @description 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public record JsonRpcRequest(String method, JsonNode params, JsonNode id) { /** * id가 없는 요청인지 확인하여 JSON-RPC notification 여부를 판단합니다. + * + * @return 조건 충족 여부를 반환합니다. */ public boolean notification() { return id == null || id.isNull(); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java index 9ad7a78..9c9eded 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcRequestParser.java @@ -7,14 +7,28 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. 설정된 MCP POST endpoint의 모든 요청이 이 클래스를 지나며 여기서 JSON 구조를 검사합니다. HTTP 경계 로그는 filter가 - * 담당하므로 이 parser는 별도 trace logger를 사용하지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcRequestParser + * @description HTTP 본문에서 역직렬화된 JSON을 서버 내부의 {@link JsonRpcRequest}로 바꾸는 JSON-RPC parser입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class JsonRpcRequestParser { /** * HTTP 본문에서 읽은 JSON 객체를 서버 내부의 {@link JsonRpcRequest}로 변환합니다. envelope를 먼저 검증하며 params가 없으면 비어 있는 JSON 객체를 사용합니다. 지원 method 여부는 handler registry가 확인합니다. + * + * @param envelope 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ public JsonRpcRequest parse(JsonNode envelope) { try { @@ -33,6 +47,8 @@ public class JsonRpcRequestParser { /** * JSON-RPC 2.0 요청 envelope의 필수 구조와 타입을 검사합니다. + * + * @param envelope 처리할 요청 정보입니다. */ private void validate(JsonNode envelope) { if (envelope == null || !envelope.isObject()) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java index 8954cd7..8a7bcec 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/jsonrpc/JsonRpcResponse.java @@ -8,14 +8,29 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. {@code McpController}와 {@code McpExceptionHandler}가 설정된 MCP endpoint의 응답 본문으로 사용하며, 성공과 - * 오류를 동시에 넣지 않습니다. 주요 의존성은 request ID correlation을 위한 {@link JsonNode}와 null 필드를 제외하는 Jackson 직렬화 설정입니다. + * @package io.shinhanlife.dat.biz.mcp.jsonrpc + * @className JsonRpcResponse + * @description MCP method handler의 성공 result 또는 JSON-RPC 표준 error를 담는 불변 응답 envelope입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @JsonInclude(JsonInclude.Include.NON_NULL) public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNode id) { /** * 정상 처리 결과와 원 요청 ID를 JSON-RPC 2.0 성공 응답으로 감쌉니다. + * + * @param id 입력값입니다. + * @param result 입력값입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ public static JsonRpcResponse success(JsonNode id, Object result) { return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, result, null, id); @@ -23,6 +38,10 @@ public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNo /** * 표준 오류 정보와 원 요청 ID를 JSON-RPC 2.0 실패 응답으로 감쌉니다. + * + * @param id 입력값입니다. + * @param error 처리 중 발생한 예외 정보입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ public static JsonRpcResponse failure(JsonNode id, Error error) { return new JsonRpcResponse(McpSchema.JSONRPC_VERSION, null, error, id); @@ -30,6 +49,11 @@ public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNo /** * 내부 오류 코드와 상세 정보를 guid가 포함된 JSON-RPC 실패 응답으로 변환합니다. + * + * @param id 입력값입니다. + * @param code 입력값입니다. + * @param details 처리할 값입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ public static JsonRpcResponse failure(JsonNode id, JsonRpcErrorCode code, Object details) { Map data = new LinkedHashMap<>(); @@ -48,6 +72,11 @@ public record JsonRpcResponse(String jsonrpc, Object result, Error error, JsonNo /** * JSON-RPC 오류의 code·message·선택 data를 담는 하위 값 객체입니다. {@link JsonRpcResponse#failure(JsonNode, JsonRpcErrorCode, Object)}가 생성하며, Tool 업무 실패 결과에는 사용하지 않습니다. + * + * @param code 입력값입니다. + * @param message 처리할 값입니다. + * @param data 입력값입니다. + * @return 처리 결과를 반환합니다. */ @JsonInclude(JsonInclude.Include.NON_NULL) public record Error(int code, String message, Object data) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java index 1ba29d4..69af1f3 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandler.java @@ -8,8 +8,19 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse; import org.springframework.stereotype.Component; /** - * MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며, - * 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다. 주요 의존성은 서버명·버전·프로토콜 설정을 제공하는 {@link McpProperties}, MCP SDK 표준 초기화 모델, JSON-RPC 응답 envelope입니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className InitializeHandler + * @description MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며, 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class InitializeHandler implements McpMethodHandlerRegistry.Handler { @@ -18,6 +29,8 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { /** * initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public InitializeHandler(McpProperties properties) { this.properties = properties; @@ -25,6 +38,8 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { /** * 이 handler가 담당하는 MCP method 이름인 `initialize`를 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -33,6 +48,10 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { /** * Agent Builder에 protocol version, 서버 정보, 지원 capability를 알려 주는 initialize 결과를 만듭니다. 요청 ID를 그대로 응답에 넣어 JSON-RPC correlation을 유지합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java index 0181807..52dcd74 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/InitializedNotificationHandler.java @@ -10,14 +10,28 @@ import java.util.Map; import org.springframework.stereotype.Component; /** - * AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. 이 요청은 서버 상태나 세션을 만들지 않고 {@code McpController}가 HTTP 202으로 - * 마무리합니다. 별도 협력 객체 없이 표준 notification acknowledgement만 반환합니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className InitializedNotificationHandler + * @description AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class InitializedNotificationHandler implements McpMethodHandlerRegistry.Handler { /** - * 이 handler가 담당하는 `notifications/initialized` method 이름을 반환합니다. + * 이 handler가 담당하는 +otifications/initialized` method 이름을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -26,6 +40,10 @@ public class InitializedNotificationHandler implements McpMethodHandlerRegistry. /** * Agent Builder의 initialize 완료 notification을 수용합니다. 서버 상태를 생성하지 않으며 {@code McpController}가 HTTP 202 빈 응답으로 최종 처리합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java index 3be21b6..08571b6 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/McpMethodHandlerRegistry.java @@ -13,8 +13,19 @@ import java.util.Map; import org.springframework.stereotype.Component; /** - * Spring이 만든 MCP method handler를 method 문자열 기준으로 인덱싱하고, {@code McpController}의 명시적 dispatch를 지원합니다. 설정된 MCP endpoint 요청은 transport 검증 후 이 registry에서 - * initialize·tools/list·tools/call handler를 찾아 처리합니다. 주요 의존성은 {@link Handler} 구현체 목록과 지원하지 않는 method를 거절하는 JSON-RPC 오류 모델입니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className McpMethodHandlerRegistry + * @description Spring이 만든 MCP method handler를 method 문자열 기준으로 인덱싱하고, {@code McpController}의 명시적 dispatch를 지원합니다. 설정된 MCP endpoint 요청은 transport 검증 후 이 registry에서 initialize·tools/list·tools/call handler를 찾아 처리합니다. 주요 의존성은 {@link Handler} 구현체 목록과 지원하지 않는 method를 거절하는 JSON-RPC 오류 모델입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class McpMethodHandlerRegistry { @@ -23,6 +34,8 @@ public class McpMethodHandlerRegistry { /** * Spring이 찾은 모든 handler를 method 이름 기준의 읽기 전용 map으로 구성합니다. 같은 method를 담당하는 handler가 둘이면 시작 시 즉시 실패해 모호한 dispatch를 막습니다. + * + * @param handlers 처리 대상 목록입니다. */ public McpMethodHandlerRegistry(List handlers) { Map indexed = new HashMap<>(); @@ -37,6 +50,9 @@ public class McpMethodHandlerRegistry { /** * 요청 method에 맞는 handler를 반환하고, 지원하지 않으면 Method not found 오류를 발생시킵니다. + * + * @param method 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ public Handler resolve(String method) { Handler handler = handlers.get(method); @@ -54,11 +70,17 @@ public class McpMethodHandlerRegistry { /** * 이 handler가 처리할 JSON-RPC method 문자열을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ String method(); /** * 검증된 요청과 request context를 받아 method별 JSON-RPC 응답을 생성합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context); } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java index 69472da..cbcf116 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsCallHandler.java @@ -15,8 +15,19 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * MCP {@code tools/call} 요청을 받아 Tool 실행 계층으로 전달하고 MCP result 형식으로 되돌리는 method handler입니다. {@link ToolExecutionService}를 통해 Tool을 실행하고 결과는 MCP SDK의 - * {@link McpSchema.CallToolResult}로 만듭니다. 주요 의존성은 실행 서비스이며, 잘못된 요청은 최상위 JSON-RPC error로, Tool 자체 실패는 {@code result.isError=true}로 구분합니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className ToolsCallHandler + * @description MCP {@code tools/call} 요청을 받아 Tool 실행 계층으로 전달하고 MCP result 형식으로 되돌리는 method handler입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { @@ -25,6 +36,8 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 실행 서비스를 주입받습니다. + * + * @param executionService 협력 객체입니다. */ public ToolsCallHandler(ToolExecutionService executionService) { this.executionService = executionService; @@ -32,6 +45,8 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 이 handler가 담당하는 `tools/call` method 이름을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -40,6 +55,10 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 일반 tools/call 요청에서 명시된 단일 Tool을 실행합니다. Tool 실행 계열 오류는 MCP 규칙에 맞춰 최상위 error가 아닌 `isError=true` result로 변환합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { @@ -57,6 +76,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 표준 tools/call params에서 Tool 이름과 object arguments를 검증해 내부 호출 값으로 만듭니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ private ToolCall extract(JsonRpcRequest request) { String toolName = request.params().path("name").asText(null); @@ -72,6 +94,10 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * 요청 ID를 보존한 Invalid params 예외를 만듭니다. + * + * @param request 처리할 요청 정보입니다. + * @param details 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException invalid(JsonRpcRequest request, String details) { return new JsonRpcException(JsonRpcErrorCode.INVALID_PARAMS, details, request.id(), null); @@ -79,6 +105,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 실행 결과를 text content와 실행 시간 meta를 가진 MCP 성공 결과로 변환합니다. + * + * @param result 입력값입니다. + * @return 처리 결과를 반환합니다. */ private McpSchema.CallToolResult successResult(ToolExecutionService.Result result) { McpSchema.TextContent content = @@ -90,6 +119,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 응답을 MCP text content에 넣을 문자열로 바꾸며 JSON 객체와 배열은 compact JSON을 유지합니다. + * + * @param data 입력값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String asText(JsonNode data) { if (data == null || data.isNull()) { @@ -100,6 +132,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * Tool 실패 상세를 사용자에게 전달 가능한 text content와 `isError=true` 결과로 변환합니다. + * + * @param details 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private McpSchema.CallToolResult failureResult(Object details) { McpSchema.TextContent content = McpSchema.TextContent.builder(String.valueOf(details)).build(); @@ -108,6 +143,9 @@ public class ToolsCallHandler implements McpMethodHandlerRegistry.Handler { /** * JSON-RPC envelope 오류가 아니라 MCP Tool result로 표현해야 하는 실행 계열 오류인지 구분합니다. + * + * @param errorCode 처리 중 발생한 예외 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isToolExecutionFailure(JsonRpcErrorCode errorCode) { return errorCode == JsonRpcErrorCode.TOOL_EXECUTION_ERROR diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java index d3e58b4..78ae7e2 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/method/ToolsListHandler.java @@ -13,9 +13,19 @@ 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.dat.biz.mcp.registry.ToolRegistryService}이며, Jackson mapper는 local - * catalog의 공개 필드만 SDK 모델로 옮깁니다. endpoint·timeout 등 실행용 운영 정보는 응답에 노출하지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.method + * @className ToolsListHandler + * @description MCP {@code tools/list} 요청에 대해 AgentBuilder에 공개할 도구 목록을 만드는 method handler입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { @@ -25,6 +35,9 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 활성 Tool metadata를 조회할 Registry service와 SDK 모델 변환용 Jackson mapper를 주입받습니다. + * + * @param registryService 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. */ public ToolsListHandler(ToolRegistryService registryService, ObjectMapper objectMapper) { this.registryService = registryService; @@ -33,6 +46,8 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 이 handler가 담당하는 `tools/list` method 이름을 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ @Override public String method() { @@ -41,6 +56,10 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 실행용 metadata에서 외부 공개 필드만 골라 MCP tools/list 응답을 만듭니다. 내부 endpoint나 timeout 정보는 Agent Builder 응답에 노출하지 않습니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Agent Builder로 반환할 JSON-RPC 응답입니다. */ @Override public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) { @@ -49,8 +68,10 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { } /** - * 원천이 보존한 공개 Tool 정의가 있으면 SDK Tool 모델로 변환하고, 없으면 기본 공개 필드를 조립합니다. 변환 직전에 {@code _meta}를 한 번 더 제거합니다. 두 원천이 이미 제거해서 넘기지만, {@link McpSchema.Tool}은 - * {@code _meta}를 담을 수 있는 표준 필드를 가지고 있어 그대로 통과시키면 endpoint·timeout이 Agent Builder 응답에 그대로 실린다. 공개 경계 바로 앞의 마지막 방어선이다. + * 원천이 보존한 공개 Tool 정의가 있으면 SDK Tool 모델로 변환하고, 없으면 기본 공개 필드를 조립합니다. 변환 직전에 {@code _meta}를 한 번 더 제거합니다. 두 원천이 이미 제거해서 넘기지만, {@link McpSchema.Tool}은 {@code _meta}를 담을 수 있는 표준 필드를 가지고 있어 그대로 통과시키면 endpoint·timeout이 Agent Builder 응답에 그대로 실린다. 공개 경계 바로 앞의 마지막 방어선이다. + * + * @param metadata Tool 처리 정보입니다. + * @return 처리 결과를 반환합니다. */ private McpSchema.Tool toMcpTool(ToolMetadata metadata) { if (metadata.publicDefinition() != null) { @@ -64,6 +85,9 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 공개 Tool 정의를 복사해 실행용 {@code _meta}만 제거합니다. 원본 snapshot은 바꾸지 않습니다. + * + * @param definition 처리할 값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode withoutExecutionMetadata(JsonNode definition) { if (!definition.isObject() || !definition.has("_meta")) { @@ -76,6 +100,9 @@ public class ToolsListHandler implements McpMethodHandlerRegistry.Handler { /** * 기존 Registry 응답에 inputSchema가 없으면 SDK 필수 조건을 만족하는 빈 object schema로 정규화합니다. schema가 있으면 field를 변경하지 않고 Jackson Map으로 옮깁니다. + * + * @param metadata Tool 처리 정보입니다. + * @return 처리 결과를 반환합니다. */ @SuppressWarnings("unchecked") private java.util.Map toInputSchema(ToolMetadata metadata) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java index ca27bf9..744fa5d 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolBundleStatusEndpoint.java @@ -13,8 +13,19 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; /** - * 설정에 선언된 Tool Service bundle의 조회 상태를 management endpoint로 제공하는 운영 진단 구성요소입니다. MCP JSON-RPC 요청을 처리하지 않으며 Actuator가 {@code toolBundles} read operation을 호출합니다. - * 주요 의존성은 bundle별 last-good과 실패 상태를 보관하는 {@link ToolBundleDiscovery}이며, manifest URL이나 Tool schema 같은 내부 상세 정보는 응답에 포함하지 않습니다. + * @package io.shinhanlife.dat.biz.mcp.observability + * @className ToolBundleStatusEndpoint + * @description 설정에 선언된 Tool Service bundle의 조회 상태를 management endpoint로 제공하는 운영 진단 구성요소입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component @Endpoint(id = "toolBundles") @@ -26,6 +37,8 @@ public class ToolBundleStatusEndpoint { /** * 운영 조회 시 사용할 bundle discovery 상태 저장소를 주입받습니다. + * + * @param discovery 협력 객체입니다. */ public ToolBundleStatusEndpoint(ToolBundleDiscovery discovery) { this.discovery = discovery; @@ -33,6 +46,8 @@ public class ToolBundleStatusEndpoint { /** * 선언된 모든 bundle의 현재 상태를 읽기 전용 Map으로 반환합니다. 상태 조회는 manifest refresh나 Tool 실행을 유발하지 않습니다. + * + * @return Tool Service bundle 상태 목록을 반환합니다. */ @ReadOperation public Map> bundleStatuses() { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java index f98f9e6..3d32b83 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicator.java @@ -7,9 +7,19 @@ import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.stereotype.Component; /** - * Tool discovery 첫 시도와 in-memory snapshot 적재 여부로 readiness를 판정하는 health indicator입니다. MCP 요청을 직접 처리하지 않으며 {@code /actuator/health/readiness}의 readiness group에서 - * 평가됩니다. 원천 조회가 실패해도 memory 또는 Redis의 last-good snapshot이 있으면 서비스 가능 상태로 인정하지만, 사용할 snapshot이 전혀 없는 Pod은 트래픽을 받지 않습니다. 주요 협력 객체는 기동 조회 완료 시점을 제공하는 - * {@link ToolRegistryRefreshScheduler}와 요청 경로의 snapshot을 소유하는 {@link ToolRegistryService}입니다. + * @package io.shinhanlife.dat.biz.mcp.observability + * @className ToolCatalogHealthIndicator + * @description Tool discovery 첫 시도와 in-memory snapshot 적재 여부로 readiness를 판정하는 health indicator입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class ToolCatalogHealthIndicator implements HealthIndicator { @@ -19,6 +29,9 @@ public class ToolCatalogHealthIndicator implements HealthIndicator { /** * 기동 preload 시점과 usable Tool snapshot을 함께 확인할 협력 객체를 주입받습니다. + * + * @param scheduler 협력 객체입니다. + * @param registryService 협력 객체입니다. */ public ToolCatalogHealthIndicator( ToolRegistryRefreshScheduler scheduler, ToolRegistryService registryService) { @@ -28,6 +41,8 @@ public class ToolCatalogHealthIndicator implements HealthIndicator { /** * 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다. + * + * @return 처리 결과를 반환합니다. */ @Override public Health health() { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java index 4d75658..3022225 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/observability/TraceLogger.java @@ -9,9 +9,19 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; /** - * MCP HTTP 입출구와 Tool HTTP 호출 경계의 이벤트를 한 줄 key=value 로그로 남깁니다. - * 현재 요청의 guid와 requestId는 {@link McpRequestContextHolder}에서 읽어 로그 메시지에 직접 포함하므로 MDC를 사용하지 않습니다. - * payload 본문은 이 공통 logger가 보관하지 않고, 임시 검증이 필요한 경계 클래스에서 삭제하기 쉬운 별도 {@code TEMP_*} 로그로만 남깁니다. + * @package io.shinhanlife.dat.biz.mcp.observability + * @className TraceLogger + * @description TraceLogger 구성요소입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class TraceLogger { @@ -21,14 +31,18 @@ public class TraceLogger { /** * trace 로그 활성화 여부를 판단할 설정 객체를 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public TraceLogger(McpProperties properties) { this.properties = properties; } /** - * 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. - * trace 로그 설정이 켜진 경우에만 기록하며, 요청·응답 본문은 포함하지 않습니다. + * 정상적인 처리 단계를 key=value 형식의 구조화 로그로 남깁니다. trace 로그 설정이 켜진 경우에만 기록하며, 요청·응답 본문은 포함하지 않습니다. + * + * @param event 처리할 값입니다. + * @param keyValues 처리 대상 목록입니다. */ public void event(String event, Object... keyValues) { if (properties.trace().enabled()) { @@ -43,8 +57,11 @@ public class TraceLogger { } /** - * 예외가 발생한 처리 단계를 오류 로그로 남깁니다. - * 오류 로그에는 payload를 포함하지 않고 예외 종류와 메시지만 correlation 값과 함께 남깁니다. + * 예외가 발생한 처리 단계를 오류 로그로 남깁니다. 오류 로그에는 payload를 포함하지 않고 예외 종류와 메시지만 correlation 값과 함께 남깁니다. + * + * @param event 처리할 값입니다. + * @param error 처리 중 발생한 예외 정보입니다. + * @param keyValues 처리 대상 목록입니다. */ public void error(String event, Throwable error, Object... keyValues) { McpRequestContext context = McpRequestContextHolder.get().orElse(null); @@ -60,8 +77,10 @@ public class TraceLogger { } /** - * 가변 인자로 받은 key/value 쌍을 사람이 읽기 쉬운 key=value 문자열로 변환합니다. - * 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다. + * 가변 인자로 받은 key/value 쌍을 사람이 읽기 쉬운 key=value 문자열로 변환합니다. 홀수 개가 들어오면 짝이 없는 마지막 값은 기록하지 않습니다. + * + * @param keyValues 처리 대상 목록입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String fields(Object... keyValues) { StringJoiner joiner = new StringJoiner(" "); @@ -73,6 +92,9 @@ public class TraceLogger { /** * 줄바꿈과 공백을 치환해 로그 이벤트가 여러 줄로 갈라지지 않게 합니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String safe(Object value) { if (value == null) { @@ -83,6 +105,9 @@ public class TraceLogger { /** * 요청 context가 있을 때 end-to-end 상관 값 guid를 반환하고, background 로그에는 빈 값을 사용합니다. + * + * @param context 현재 MCP 요청 context입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String guid(McpRequestContext context) { return context == null ? "" : safe(context.guid()); @@ -90,6 +115,9 @@ public class TraceLogger { /** * 요청 context가 있을 때 개별 HTTP request ID를 반환하고, background 로그에는 빈 값을 사용합니다. + * + * @param context 현재 MCP 요청 context입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String requestId(McpRequestContext context) { return context == null ? "" : safe(context.requestId()); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java index 369c4eb..06a864e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java @@ -15,12 +15,23 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; -/** - * 매니페스트 조회를 끈 local profile에서 Agent Builder {@code tools/list} 응답 형식의 JSON 파일을 실행용 {@link ToolMetadata}로 변환하는 adapter입니다. {@code tools/list}와 local - * {@code tools/call}이 metadata를 필요로 할 때 {@link ToolRegistryService}가 cache miss 후 호출합니다. 주요 의존성은 설정의 local 파일 경로를 제공하는 McpProperties와 JSON 파싱용 ObjectMapper이며, - * 운영 HTTP Registry를 호출하지 않습니다. - */ @Component + +/** + * @package io.shinhanlife.dat.biz.mcp.registry + * @className LocalFileToolRegistryClient + * @description 매니페스트 조회를 끈 local profile에서 Agent Builder {@code tools/list} 응답 형식의 JSON 파일을 실행용 {@link ToolMetadata}로 변환하는 adapter입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
+ */ @Profile("local") @ConditionalOnExpression("!${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") public class LocalFileToolRegistryClient implements ToolRegistryClient { @@ -31,6 +42,10 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * local Tool catalog의 resource loader, JSON mapper, 파일 위치 설정을 주입받습니다. + * + * @param resourceLoader 처리할 값입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public LocalFileToolRegistryClient( ResourceLoader resourceLoader, ObjectMapper objectMapper, McpProperties properties) { @@ -41,6 +56,9 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * local profile에서 설정된 JSON 파일의 {@code result.tools[]}를 읽어 실행 metadata 목록으로 변환합니다. 파일이 없거나 읽을 수 없거나 내용이 비어 있으면 Registry unavailable 오류로 변환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public List fetchTools(String routeKey) { @@ -58,6 +76,10 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * Agent Builder tools/list 응답 또는 Tool Service manifest의 공개 정의와 {@code _meta} 실행 정보를 내부 ToolMetadata로 조합합니다. 두 형식 모두 배열이 없으면 오류로 처리해 빈 목록을 조용히 반환하지 않습니다. + * + * @param document 입력값입니다. + * @param location 처리할 값입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List toToolMetadataList(JsonNode document, String location) { JsonNode tools = document.path("result").path("tools"); @@ -75,8 +97,11 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { } /** - * 한 공개 Tool 정의에서 name·description·inputSchema와 {@code _meta}의 endpoint·timeout·상태를 추출합니다. 실행에 필수인 name 또는 endpoint가 없으면 local 설정 오류로 처리해 잘못된 Tool 호출을 - * 막습니다. + * 한 공개 Tool 정의에서 name·description·inputSchema와 {@code _meta}의 endpoint·timeout·상태를 추출합니다. 실행에 필수인 name 또는 endpoint가 없으면 local 설정 오류로 처리해 잘못된 Tool 호출을 막습니다. + * + * @param tool Tool 처리 정보입니다. + * @param location 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private ToolMetadata toToolMetadata(JsonNode tool, String location) { JsonNode meta = tool.path("_meta"); @@ -99,6 +124,9 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * local 파일의 공개 Tool 정의를 복사하고 내부 실행 metadata인 {@code _meta}만 제거합니다. + * + * @param tool Tool 처리 정보입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode publicDefinition(JsonNode tool) { ObjectNode definition = ((ObjectNode) tool).deepCopy(); @@ -108,6 +136,11 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * local sample의 필수 문자열 field를 검증하고 누락 시 Registry unavailable 오류로 바꿉니다. + * + * @param source 처리할 값입니다. + * @param fieldName 대상 이름입니다. + * @param location 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String requiredText(JsonNode source, String fieldName, String location) { String value = source.path(fieldName).asText(null); @@ -119,6 +152,11 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { /** * 파일 위치와 실패 이유를 포함하되 원문 payload는 노출하지 않는 Registry 오류를 만듭니다. + * + * @param location 처리할 값입니다. + * @param message 처리할 값입니다. + * @param cause 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException unavailable(String location, String message, Exception cause) { String details = message + ": " + location; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java index cac92d7..ca0cc5e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClient.java @@ -27,11 +27,19 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; /** - * Portal Registry 응답을 Tool Server endpoint 원천으로 변환하는 adapter입니다. - * MCP 요청을 직접 처리하지 않고 {@link ToolRegistryService}의 기동 preload와 주기 refresh 단계에서 호출되며, - * HTTP(S) Portal API 또는 로컬 리소스 파일을 같은 registry JSON 계약으로 읽은 뒤 기존 {@link ToolBundleDiscovery} 검증 경로에 연결합니다. - * 주요 의존성은 Portal/리소스 조회를 담당하는 {@link RestClient}와 {@link ResourceLoader}, JSON 파서를 제공하는 {@link ObjectMapper}, - * Tool Service manifest 검증을 담당하는 {@link ToolBundleDiscovery}, 그리고 portal/discovery 정책을 제공하는 {@link McpProperties}입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className PortalToolRegistryClient + * @description Portal Registry 응답을 Tool Server endpoint 원천으로 변환하는 adapter입니다. + * @author j.h.w + * @create 2026.08.11 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.11   j.h.w         최초생성
+ *
+ * 
*/ @Component @ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "true") @@ -51,9 +59,14 @@ public class PortalToolRegistryClient implements ToolRegistryClient { new java.util.concurrent.ConcurrentHashMap<>(); /** - * Portal Registry 조회 client와 로컬 리소스 reader, 기존 Tool Service manifest discovery를 주입받습니다. - * registry 위치가 HTTP(S)이면 {@link RestClient}를 사용하고, {@code file:} 또는 {@code classpath:}이면 {@link ResourceLoader}와 {@link ObjectMapper}로 읽어 - * 동일한 endpoint snapshot 변환 경로에 전달합니다. + * Portal Registry 조회 client와 로컬 리소스 reader, 기존 Tool Service manifest discovery를 주입받습니다. registry 위치가 HTTP(S)이면 {@link RestClient}를 사용하고, {@code file:} 또는 {@code classpath:}이면 {@link ResourceLoader}와 {@link ObjectMapper}로 읽어 동일한 endpoint snapshot 변환 경로에 전달합니다. + * + * @param restClient 협력 객체입니다. + * @param properties MCP 설정 정보입니다. + * @param discovery 협력 객체입니다. + * @param redisPortalRegistryCache 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param resourceLoader 처리할 값입니다. */ public PortalToolRegistryClient( @Qualifier("manifestRestClient") RestClient restClient, @@ -71,8 +84,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 registry에서 현재 route 목록을 확인하고 지정 route의 Tool Service manifest를 다시 조회합니다. - * 포털은 endpoint 목록의 원천으로만 사용하며, route가 비어 있거나 없으면 registry unavailable 오류로 처리합니다. + * 포털 registry에서 현재 route 목록을 확인하고 지정 route의 Tool Service manifest를 다시 조회합니다. 포털은 endpoint 목록의 원천으로만 사용하며, route가 비어 있거나 없으면 registry unavailable 오류로 처리합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public List fetchTools(String routeKey) { @@ -86,8 +101,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. - * 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다. + * 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public Map> fetchAllTools() { @@ -98,8 +114,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 memory에 갱신합니다. - * manifest 조회는 수행하지 않으며, 실패하면 기존 endpoint 목록이나 Redis fallback 규칙을 호출자에게 전달합니다. + * 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 memory에 갱신합니다. manifest 조회는 수행하지 않으며, 실패하면 기존 endpoint 목록이나 Redis fallback 규칙을 호출자에게 전달합니다. + * + * @return 조건 충족 여부를 반환합니다. */ @Override public boolean refreshSourceRegistry() { @@ -109,8 +126,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * Portal registry endpoint snapshot에 현재 route key가 존재하는지 request path에서 빠르게 확인합니다. - * 네트워크 조회를 새로 수행하지 않고 이미 적재된 {@code bundlesByRoute}만 읽으므로 잘못된 route가 controller와 Tool 실행 계층으로 들어가지 못하게 하는 1차 방어선입니다. + * Portal registry endpoint snapshot에 현재 route key가 존재하는지 request path에서 빠르게 확인합니다. 네트워크 조회를 새로 수행하지 않고 이미 적재된 {@code bundlesByRoute}만 읽으므로 잘못된 route가 controller와 Tool 실행 계층으로 들어가지 못하게 하는 1차 방어선입니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ @Override public boolean isKnownRoute(String routeKey) { @@ -122,8 +141,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * Portal API를 먼저 조회하고, 실패 시 기존 memory endpoint snapshot 또는 Redis fallback으로 대체합니다. - * 이미 memory가 있으면 Redis를 읽지 않고 기존 snapshot을 유지하며, cold start처럼 memory가 없을 때만 Redis registry JSON을 마지막 fallback으로 사용합니다. + * Portal API를 먼저 조회하고, 실패 시 기존 memory endpoint snapshot 또는 Redis fallback으로 대체합니다. 이미 memory가 있으면 Redis를 읽지 않고 기존 snapshot을 유지하며, cold start처럼 memory가 없을 때만 Redis registry JSON을 마지막 fallback으로 사용합니다. + * + * @param registryUrl 처리할 값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode loadPortalRegistryWithFallback(String registryUrl) { try { @@ -146,8 +167,11 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * Portal 또는 Redis에서 읽은 registry JSON을 route별 endpoint memory snapshot으로 반영합니다. - * route key는 포털 응답 안에 반드시 있어야 하며, 설정 기본 route로 보정하지 않습니다. + * Portal 또는 Redis에서 읽은 registry JSON을 route별 endpoint memory snapshot으로 반영합니다. route key는 포털 응답 안에 반드시 있어야 하며, 설정 기본 route로 보정하지 않습니다. + * + * @param registryUrl 처리할 값입니다. + * @param registry 처리할 값입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean registerPortalRegistry(String registryUrl, JsonNode registry) { if (registry == null) { @@ -174,9 +198,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 설정된 registry 위치에서 route별 Tool Server endpoint registry JSON을 읽고 기본 응답 shape를 검증합니다. - * {@code http:}/{@code https:} 위치는 Portal API를 호출하고, {@code file:}/{@code classpath:} 위치는 로컬 리소스를 읽어 - * 개발 환경에서 Portal 서버 없이도 같은 snapshot 갱신 흐름을 검증할 수 있게 합니다. + * 설정된 registry 위치에서 route별 Tool Server endpoint registry JSON을 읽고 기본 응답 shape를 검증합니다. {@code http:}/{@code https:} 위치는 Portal API를 호출하고, {@code file:}/{@code classpath:} 위치는 로컬 리소스를 읽어 개발 환경에서 Portal 서버 없이도 같은 snapshot 갱신 흐름을 검증할 수 있게 합니다. + * + * @param registryUrl 처리할 값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode portalRegistry(String registryUrl) { JsonNode registry = isResourceRegistry(registryUrl) @@ -189,8 +214,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * Portal Registry HTTP API를 호출해 registry JSON을 가져옵니다. - * 호출 실패는 상위 refresh fallback 정책이 판단할 수 있도록 그대로 예외로 전달합니다. + * Portal Registry HTTP API를 호출해 registry JSON을 가져옵니다. 호출 실패는 상위 refresh fallback 정책이 판단할 수 있도록 그대로 예외로 전달합니다. + * + * @param registryUrl 처리할 값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode portalRegistryHttp(String registryUrl) { return restClient.get() @@ -200,8 +227,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 로컬 파일 또는 classpath resource에서 registry JSON을 읽습니다. - * 파일이 없거나 JSON 파싱이 실패하면 endpoint 원천을 사용할 수 없는 상태로 변환해 기존 memory/Redis fallback 규칙을 유지합니다. + * 로컬 파일 또는 classpath resource에서 registry JSON을 읽습니다. 파일이 없거나 JSON 파싱이 실패하면 endpoint 원천을 사용할 수 없는 상태로 변환해 기존 memory/Redis fallback 규칙을 유지합니다. + * + * @param registryLocation 처리할 값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode portalRegistryResource(String registryLocation) { Resource resource = resourceLoader.getResource(registryLocation); @@ -216,16 +245,21 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * registry 위치가 네트워크 Portal API인지 Spring resource location인지 구분합니다. - * 현재 로컬 검증 용도는 {@code file:}과 {@code classpath:}만 허용해 실수로 HTTP URL을 resource로 해석하지 않습니다. + * registry 위치가 네트워크 Portal API인지 Spring resource location인지 구분합니다. 현재 로컬 검증 용도는 {@code file:}과 {@code classpath:}만 허용해 실수로 HTTP URL을 resource로 해석하지 않습니다. + * + * @param registryLocation 처리할 값입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isResourceRegistry(String registryLocation) { return registryLocation.startsWith("file:") || registryLocation.startsWith("classpath:"); } /** - * 포털 전체 registry 응답을 최초 수신하거나 {@code registryRevision}이 바뀐 경우에만 INFO 로그로 남깁니다. - * 로컬 검증용 로그이므로 endpoint와 Tool Server 설정을 포함한 응답 JSON 전체를 그대로 보여 줍니다. + * 포털 전체 registry 응답을 최초 수신하거나 {@code registryRevision}이 바뀐 경우에만 INFO 로그로 남깁니다. 로컬 검증용 로그이므로 endpoint와 Tool Server 설정을 포함한 응답 JSON 전체를 그대로 보여 줍니다. + * + * @param registryUrl 처리할 값입니다. + * @param registry 처리할 값입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean logPortalRegistryIfChanged(String registryUrl, JsonNode registry) { String revision = registry.path("registryRevision").asText(""); @@ -244,8 +278,11 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 단일 route의 endpoint 목록을 route별 Tool metadata snapshot으로 변환합니다. - * 각 Tool Service manifest 조회 결과는 기존 discovery 검증과 merge 규칙을 통과해야 합니다. + * 단일 route의 endpoint 목록을 route별 Tool metadata snapshot으로 변환합니다. 각 Tool Service manifest 조회 결과는 기존 discovery 검증과 merge 규칙을 통과해야 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param bundles 처리 대상 목록입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List fetchRouteTools(String routeKey, List bundles) { List results = discovery.discoverAll(bundles); @@ -253,8 +290,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 최초 기동 또는 cache가 비어 있는 요청 시점에 포털 registry를 조회합니다. - * 이후 manifest 주기 refresh는 저장된 endpoint 목록만 사용하므로 포털 API와 Tool Server manifest 호출 주기를 분리합니다. + * 최초 기동 또는 cache가 비어 있는 요청 시점에 포털 registry를 조회합니다. 이후 manifest 주기 refresh는 저장된 endpoint 목록만 사용하므로 포털 API와 Tool Server manifest 호출 주기를 분리합니다. */ private void ensurePortalRegistryLoaded() { if (bundlesByRoute.isEmpty()) { @@ -263,8 +299,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 에이전트 요청 경로나 포털 응답에서 받은 route key를 메모리 snapshot 조회 key로 정규화합니다. - * route key가 비어 있으면 기본 route로 보정하지 않고 registry unavailable 오류로 처리해 잘못된 단일 진입점 호출을 드러냅니다. + * 에이전트 요청 경로나 포털 응답에서 받은 route key를 메모리 snapshot 조회 key로 정규화합니다. route key가 비어 있으면 기본 route로 보정하지 않고 registry unavailable 오류로 처리해 잘못된 단일 진입점 호출을 드러냅니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String normalizeRouteKey(String routeKey) { if (routeKey == null || routeKey.isBlank()) { @@ -274,8 +312,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 Registry URL을 호출 주소로 변환합니다. - * URL에 {@code {route}} placeholder가 있으면 치환하고, 없으면 전체 registry 조회 URL로 그대로 사용합니다. + * 포털 Registry URL을 호출 주소로 변환합니다. URL에 {@code {route}} placeholder가 있으면 치환하고, 없으면 전체 registry 조회 URL로 그대로 사용합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String registryUrl(String routeKey) { String configured = properties.portal().registryUrl(); @@ -283,10 +323,11 @@ 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}에서 확정합니다. 필수 endpoint 정보가 빠진 서비스는 해당 논리 bundle만 제외하고, - * 사용할 수 있는 active 서비스가 하나도 없을 때만 갱신을 거부합니다. + * 포털의 active Tool Service 목록을 기존 ToolBundleDiscovery가 이해하는 bundle 선언으로 변환합니다. 포털 registry는 Tool Server의 domain과 manifest 위치만 제공하므로 개별 Tool endpoint 목록은 읽지 않습니다. Tool 실행 endpoint는 이후 Tool Server manifest의 {@code _meta.endpoint}에서 확정합니다. 필수 endpoint 정보가 빠진 서비스는 해당 논리 bundle만 제외하고, 사용할 수 있는 active 서비스가 하나도 없을 때만 갱신을 거부합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param services 입력값입니다. + * @return 조회된 선택값을 반환합니다. */ private Optional> toBundles(String routeKey, JsonNode services) { List bundles = new ArrayList<>(); @@ -304,9 +345,11 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털의 Tool Service 한 건을 manifest discovery용 bundle로 변환합니다. - * serviceKey, serviceDomain, manifestPath 중 하나라도 없으면 그 서비스만 사용할 수 없는 항목으로 보고 제외하며, 나머지 정상 서비스의 - * manifest 조회와 snapshot 갱신은 계속 진행되게 합니다. + * 포털의 Tool Service 한 건을 manifest discovery용 bundle로 변환합니다. serviceKey, serviceDomain, manifestPath 중 하나라도 없으면 그 서비스만 사용할 수 없는 항목으로 보고 제외하며, 나머지 정상 서비스의 manifest 조회와 snapshot 갱신은 계속 진행되게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param service 입력값입니다. + * @return 조회된 선택값을 반환합니다. */ private Optional toBundle(String routeKey, JsonNode service) { try { @@ -328,9 +371,10 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * Tool Service별 discovery 결과 중 사용 가능한 snapshot만 하나의 MCP Tool catalog로 병합합니다. - * 일부 Tool Service가 아직 한 번도 성공하지 못했거나 일시 장애여도 다른 성공 bundle의 Tool은 유지하며, 모든 bundle이 사용할 수 없을 때만 route 갱신을 실패 처리합니다. - * Tool name 중복과 전체 상한 초과는 성공 bundle 사이에서도 불완전한 snapshot을 만들 수 있으므로 그대로 실패 처리합니다. + * Tool Service별 discovery 결과 중 사용 가능한 snapshot만 하나의 MCP Tool catalog로 병합합니다. 일부 Tool Service가 아직 한 번도 성공하지 못했거나 일시 장애여도 다른 성공 bundle의 Tool은 유지하며, 모든 bundle이 사용할 수 없을 때만 route 갱신을 실패 처리합니다. Tool name 중복과 전체 상한 초과는 성공 bundle 사이에서도 불완전한 snapshot을 만들 수 있으므로 그대로 실패 처리합니다. + * + * @param results 처리 대상 목록입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List merge(List results) { List usableResults = results.stream() @@ -361,8 +405,11 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 응답의 필수 문자열 필드를 읽고 누락 시 registry 구성 오류로 변환합니다. - * 원문 payload를 오류 메시지에 포함하지 않아 포털 응답의 민감 정보가 로그로 노출되지 않게 합니다. + * 포털 응답의 필수 문자열 필드를 읽고 누락 시 registry 구성 오류로 변환합니다. 원문 payload를 오류 메시지에 포함하지 않아 포털 응답의 민감 정보가 로그로 노출되지 않게 합니다. + * + * @param node 처리할 값입니다. + * @param field 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String required(JsonNode node, String field) { String value = node.path(field).asText(null); @@ -374,6 +421,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { /** * 앞에 slash가 붙은 manifest path를 service domain 뒤에 붙일 수 있는 내부 경로 형태로 정규화합니다. + * + * @param path 입력값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String normalizePath(String path) { return "/" + path.replaceAll("^/+", ""); @@ -381,6 +431,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { /** * service domain 또는 실행 base endpoint 끝의 중복 slash를 제거해 routing 결과를 안정화합니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String trimTrailingSlash(String value) { return value.replaceAll("/+$", ""); @@ -388,6 +441,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { /** * registry 원천 오류를 표준 JSON-RPC registry unavailable 예외로 변환합니다. + * + * @param message 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException unavailable(String message) { return new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, message); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java index c1af76c..c7587ae 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisPortalRegistryCache.java @@ -13,9 +13,19 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; /** - * Portal Registry API 장애 시 endpoint registry JSON을 읽는 선택적 Redis fallback adapter입니다. - * MCP 요청을 직접 처리하지 않고 background preload/refresh 단계에서만 사용되며, Redis 값은 포털 DB/API의 - * 보조 복제본으로 취급합니다. 주요 외부 경계는 포털이 기록하는 Redis key와 JSON 구조입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className RedisPortalRegistryCache + * @description Portal Registry API 장애 시 endpoint registry JSON을 읽는 선택적 Redis fallback adapter입니다. + * @author j.h.w + * @create 2026.08.12 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.12   j.h.w         최초생성
+ *
+ * 
*/ @Component @ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") @@ -28,8 +38,11 @@ public class RedisPortalRegistryCache { private final String cacheKey; /** - * Redis 접근 객체와 JSON mapper, MCP 설정에서 포털 registry fallback key를 구성합니다. - * Redis가 꺼져 있으면 Spring 조건에 의해 생성되지 않으며, key 값은 운영에서 포털과 합의한 값으로 덮어씁니다. + * Redis 접근 객체와 JSON mapper, MCP 설정에서 포털 registry fallback key를 구성합니다. Redis가 꺼져 있으면 Spring 조건에 의해 생성되지 않으며, key 값은 운영에서 포털과 합의한 값으로 덮어씁니다. + * + * @param redisTemplate 입력값입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public RedisPortalRegistryCache( StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { @@ -39,8 +52,9 @@ public class RedisPortalRegistryCache { } /** - * 포털이 Redis에 저장한 aggregate registry JSON을 읽습니다. - * key miss, Redis 장애, JSON 파싱 오류는 모두 cache miss로 처리해 포털 API나 memory snapshot의 정상 동작을 막지 않습니다. + * 포털이 Redis에 저장한 aggregate registry JSON을 읽습니다. key miss, Redis 장애, JSON 파싱 오류는 모두 cache miss로 처리해 포털 API나 memory snapshot의 정상 동작을 막지 않습니다. + * + * @return 조회된 선택값을 반환합니다. */ public Optional loadRegistry() { try { @@ -60,6 +74,8 @@ public class RedisPortalRegistryCache { /** * 운영 진단과 테스트에서 포털과 합의한 Redis key를 확인할 수 있게 반환합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ public String key() { return cacheKey; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java index b3a364a..6709d2b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java @@ -17,18 +17,24 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; /** - * MCP replica 사이에서 Tool snapshot을 route별로 공유하는 선택적 Redis cache adapter입니다. - * 원천이 아니라 공유 지점이므로 조회 성공 결과만 저장하고 읽기, 쓰기, 직렬화 실패는 모두 cache miss로 격리합니다. - * {@code tools/list} 요청 경로에서는 호출하지 않으며 {@link ToolRegistryService}의 배경 갱신과 warm start에서만 사용합니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className RedisToolRegistryCache + * @description MCP replica 사이에서 Tool snapshot을 route별로 공유하는 선택적 Redis cache adapter입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component @ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") public class RedisToolRegistryCache { - /** - * route별 key 구조를 포함하는 Tool snapshot cache schema version입니다. - * 기존 단일 {@code :all} key와 섞이지 않도록 version을 올려 서로 다른 route의 Tool 목록이 같은 key를 공유하지 않게 합니다. - */ static final String CACHE_SCHEMA_VERSION = "v2"; private static final Logger logger = LoggerFactory.getLogger(RedisToolRegistryCache.class); @@ -39,8 +45,11 @@ public class RedisToolRegistryCache { private final Duration ttl; /** - * Redis 접근, JSON 변환, route별 key prefix와 TTL 설정을 주입받아 공유 cache를 구성합니다. - * 이 생성자는 외부 요청을 처리하지 않고, 이후 route별 load/save 호출에서 key를 완성합니다. + * Redis 접근, JSON 변환, route별 key prefix와 TTL 설정을 주입받아 공유 cache를 구성합니다. 이 생성자는 외부 요청을 처리하지 않고, 이후 route별 load/save 호출에서 key를 완성합니다. + * + * @param redisTemplate 입력값입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public RedisToolRegistryCache( StringRedisTemplate redisTemplate, ObjectMapper objectMapper, McpProperties properties) { @@ -53,32 +62,38 @@ public class RedisToolRegistryCache { } /** - * 기존 단일 route 호출부와 테스트가 사용하는 기본 route Redis key를 반환합니다. - * 실제 route별 진단에는 {@link #key(String)}를 사용합니다. + * 기존 단일 route 호출부와 테스트가 사용하는 기본 route Redis key를 반환합니다. 실제 route별 진단에는 {@link #key(String)}를 사용합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ public String key() { return key(""); } /** - * 지정 route가 사용하는 Redis key를 반환합니다. - * route 원문은 key 구분자와 충돌하지 않도록 URL-safe Base64 token으로 변환합니다. + * 지정 route가 사용하는 Redis key를 반환합니다. route 원문은 key 구분자와 충돌하지 않도록 URL-safe Base64 token으로 변환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 처리된 문자열 값을 반환합니다. */ public String key(String routeKey) { return cacheKeyPrefix + ":" + routeToken(routeKey); } /** - * 기본 route의 Tool snapshot을 읽습니다. - * route별 호출부는 {@link #loadSnapshot(String)}를 사용해 다른 route와 cache가 섞이지 않게 합니다. + * 기본 route의 Tool snapshot을 읽습니다. route별 호출부는 {@link #loadSnapshot(String)}를 사용해 다른 route와 cache가 섞이지 않게 합니다. + * + * @return 조회된 선택값을 반환합니다. */ public Optional> loadSnapshot() { return loadSnapshot(""); } /** - * 지정 route의 Tool snapshot을 읽습니다. - * key miss, Redis 장애, 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 refresh 정책으로 진행하게 합니다. + * 지정 route의 Tool snapshot을 읽습니다. key miss, Redis 장애, 역직렬화 오류를 모두 빈 Optional로 처리해 호출자가 자기 refresh 정책으로 진행하게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회된 선택값을 반환합니다. */ public Optional> loadSnapshot(String routeKey) { try { @@ -98,16 +113,19 @@ public class RedisToolRegistryCache { } /** - * 기본 route의 Tool snapshot을 저장합니다. - * route별 저장은 {@link #saveSnapshot(String, List)}를 사용합니다. + * 기본 route의 Tool snapshot을 저장합니다. route별 저장은 {@link #saveSnapshot(String, List)}를 사용합니다. + * + * @param tools 처리 대상 목록입니다. */ public void saveSnapshot(List tools) { saveSnapshot("", tools); } /** - * 지정 route의 Tool snapshot을 공유 지점에 저장하고 TTL을 설정합니다. - * 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신 성공 여부를 바꾸지 않습니다. + * 지정 route의 Tool snapshot을 공유 지점에 저장하고 TTL을 설정합니다. 저장 실패는 로그만 남기며 MCP 응답이나 배경 갱신 성공 여부를 바꾸지 않습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param tools 처리 대상 목록입니다. */ public void saveSnapshot(String routeKey, List tools) { try { @@ -120,8 +138,10 @@ public class RedisToolRegistryCache { } /** - * Redis key에 넣을 route token을 만듭니다. - * 빈 route는 기존 단일 route와 호환되는 고정 token으로 두고, 나머지는 URL-safe Base64로 구분자 충돌을 피합니다. + * Redis key에 넣을 route token을 만듭니다. 빈 route는 기존 단일 route와 호환되는 고정 token으로 두고, 나머지는 URL-safe Base64로 구분자 충돌을 피합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String routeToken(String routeKey) { if (routeKey == null || routeKey.isBlank()) { @@ -134,6 +154,10 @@ public class RedisToolRegistryCache { /** * payload와 credential은 남기지 않고 Redis 실패 작업, route, 예외 타입만 기록합니다. + * + * @param operation 처리할 값입니다. + * @param routeKey 처리 대상 route key입니다. + * @param exception 처리 중 발생한 예외 정보입니다. */ private void logFailure(String operation, String routeKey, Exception exception) { logger.warn( diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java index ea2f45b..fdd57f1 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java @@ -32,9 +32,19 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; /** - * 설정에 선언된 Tool Service bundle의 매니페스트를 동시에 조회·검증하고 bundle별 상태를 보관하는 discovery 구성요소입니다. MCP 요청을 직접 처리하지 않으며 {@link ToolBundleRegistryClient}의 배경 갱신에서만 호출됩니다. 개별 - * bundle의 실패는 예외가 아니라 결과값으로 반환해, 한 bundle의 장애가 나머지 bundle의 성공분까지 버리지 않게 합니다. 최초 원격 조회가 실패한 경우에만 설정된 local manifest를 cold-start fallback으로 사용합니다. 주요 의존성은 bundle - * 전용 RestClient, JSON mapper, ResourceLoader, {@link McpProperties}의 bundle·discovery 설정입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolBundleDiscovery + * @description 설정에 선언된 Tool Service bundle의 매니페스트를 동시에 조회·검증하고 bundle별 상태를 보관하는 discovery 구성요소입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component @ConditionalOnExpression("${mcp.discovery.enabled:false} || ${mcp.portal.enabled:false}") @@ -51,6 +61,10 @@ public class ToolBundleDiscovery { /** * bundle 매니페스트 조회용 RestClient, JSON mapper, 조회 정책 설정을 주입받습니다. local fallback 파일은 Spring ResourceLoader로 읽어 file:과 classpath: 위치를 모두 지원합니다. + * + * @param restClient 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. */ public ToolBundleDiscovery( @Qualifier("manifestRestClient") RestClient restClient, @@ -64,14 +78,18 @@ public class ToolBundleDiscovery { /** * 활성 bundle 전체를 동시에 조회해 bundle별 결과를 반환합니다. 순차 조회는 소요 시간이 합산되어 기동과 갱신을 지연시키므로 virtual thread로 병렬 조회하며, 각 작업이 자기 예외를 결과값으로 변환하므로 이 method는 예외를 던지지 않습니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List discoverAll() { return discoverAll(properties.enabledBundles()); } /** - * 전달받은 Tool Service bundle 목록을 동시에 조회하고 bundle별 성공본 또는 last-good 결과를 반환합니다. - * Portal Registry client가 포털 응답을 임시 bundle 모델로 변환한 뒤 이 메서드를 호출합니다. + * 전달받은 Tool Service bundle 목록을 동시에 조회하고 bundle별 성공본 또는 last-good 결과를 반환합니다. Portal Registry client가 포털 응답을 임시 bundle 모델로 변환한 뒤 이 메서드를 호출합니다. + * + * @param targets 처리 대상 목록입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List discoverAll(List targets) { if (targets.isEmpty()) { @@ -94,6 +112,10 @@ public class ToolBundleDiscovery { /** * 완료된 조회 작업의 결과를 꺼내되, 작업 자체가 비정상 종료했으면 빈 결과로 대체합니다. {@link #discoverOne}이 이미 모든 RuntimeException을 값으로 바꾸므로 이 경로는 예상 밖의 오류에 대한 마지막 방어선입니다. + * + * @param future 입력값입니다. + * @param bundle 입력값입니다. + * @return 처리 결과를 반환합니다. */ private BundleResult resultOrEmpty(Future future, Bundle bundle) { try { @@ -108,8 +130,10 @@ public class ToolBundleDiscovery { } /** - * bundle 하나의 매니페스트를 조회·검증하고 그 결과로 bundle 상태를 갱신합니다. 성공하면 새 Tool 목록을 채택하고 실패 횟수를 초기화하며, 실패하면 직전 성공본을 유지한 채 실패 횟수만 올립니다. 통신 실패만으로 Tool을 제거하지 않으며 정상 - * manifest에서 제거가 확인될 때만 새 목록을 채택합니다. + * bundle 하나의 매니페스트를 조회·검증하고 그 결과로 bundle 상태를 갱신합니다. 성공하면 새 Tool 목록을 채택하고 실패 횟수를 초기화하며, 실패하면 직전 성공본을 유지한 채 실패 횟수만 올립니다. 통신 실패만으로 Tool을 제거하지 않으며 정상 manifest에서 제거가 확인될 때만 새 목록을 채택합니다. + * + * @param bundle 입력값입니다. + * @return 처리 결과를 반환합니다. */ BundleResult discoverOne(Bundle bundle) { BundleState state = states.computeIfAbsent(bundle.id(), id -> new BundleState()); @@ -131,8 +155,10 @@ public class ToolBundleDiscovery { } /** - * 매니페스트를 HTTP로 읽어 크기 상한과 스키마 규칙을 검증한 뒤 실행 metadata 목록으로 변환합니다. 검증에 어긋나면 해당 Tool만 걸러내지 않고 bundle 전체를 거부합니다. 일부만 반영된 카탈로그는 잘못된 이름으로 조용히 실행되거나 필요한 Tool이 사라진 - * 상태를 만들어, 직전 성공본을 유지하는 것보다 나쁘기 때문입니다. + * 매니페스트를 HTTP로 읽어 크기 상한과 스키마 규칙을 검증한 뒤 실행 metadata 목록으로 변환합니다. 검증에 어긋나면 해당 Tool만 걸러내지 않고 bundle 전체를 거부합니다. 일부만 반영된 카탈로그는 잘못된 이름으로 조용히 실행되거나 필요한 Tool이 사라진 상태를 만들어, 직전 성공본을 유지하는 것보다 나쁘기 때문입니다. + * + * @param bundle 입력값입니다. + * @return 처리 결과를 반환합니다. */ private Manifest fetchAndValidate(Bundle bundle) { return parseAndValidate(bundle, fetchManifestBody(bundle)); @@ -140,6 +166,11 @@ public class ToolBundleDiscovery { /** * 원격 매니페스트를 한 번도 받지 못한 bundle의 설정된 local manifest를 읽어 검증합니다. 정상 원격 snapshot이 있으면 호출하지 않으므로 테스트 파일이 운영 목록을 덮어쓰지 않습니다. + * + * @param bundle 입력값입니다. + * @param state 입력값입니다. + * @param remoteFailure 입력값입니다. + * @return 처리 결과를 반환합니다. */ private BundleResult loadColdStartFallback( Bundle bundle, BundleState state, RuntimeException remoteFailure) { @@ -162,6 +193,10 @@ public class ToolBundleDiscovery { /** * 원격 또는 local 원천에서 읽은 문자열을 동일한 bundle 규칙으로 검증합니다. 어느 원천이든 bundle ID·이름 접두사·schema 규칙이 다르면 전체 목록을 채택하지 않습니다. + * + * @param bundle 입력값입니다. + * @param body 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ private Manifest parseAndValidate(Bundle bundle, String body) { if (body == null || body.isBlank()) { @@ -198,6 +233,9 @@ public class ToolBundleDiscovery { /** * 설정된 local fallback 파일을 크기 상한 안에서 읽습니다. 파일 경로나 본문은 로그에 남기지 않으며, 읽기 실패는 원격 실패를 가리는 대신 기존 unavailable 처리로 이어집니다. + * + * @param location 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String fetchFallbackManifestBody(String location) { int maxBytes = properties.discovery().maxManifestBytes(); @@ -215,6 +253,9 @@ public class ToolBundleDiscovery { /** * manifest 응답을 설정 상한보다 한 byte만 더 읽어 초과 여부를 확인합니다. 전체 응답을 먼저 문자열로 적재하지 않으므로 잘못된 대용량 응답이 MCP heap을 불필요하게 소비하지 않습니다. + * + * @param bundle 입력값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String fetchManifestBody(Bundle bundle) { int maxBytes = properties.discovery().maxManifestBytes(); @@ -246,8 +287,11 @@ public class ToolBundleDiscovery { } /** - * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, Tool 실행 경로는 Tool Service manifest의 {@code endpoint} 또는 {@code _meta.endpoint}에서 가져옵니다. 포털 registry가 제공한 - * {@code serviceDomain}은 상대 endpoint를 절대 URL로 바꾸는 기준으로만 사용합니다. 이름 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. + * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, Tool 실행 경로는 Tool Service manifest의 {@code endpoint} 또는 {@code _meta.endpoint}에서 가져옵니다. 포털 registry가 제공한 {@code serviceDomain}은 상대 endpoint를 절대 URL로 바꾸는 기준으로만 사용합니다. 이름 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. + * + * @param bundle 입력값입니다. + * @param tool Tool 처리 정보입니다. + * @return 처리 결과를 반환합니다. */ private ToolMetadata toToolMetadata(Bundle bundle, JsonNode tool) { String name = tool.path("name").asText(null); @@ -285,9 +329,13 @@ public class ToolBundleDiscovery { } /** - * Tool Service manifest가 제공한 Tool 실행 endpoint를 실제 호출 URL로 확정합니다. 최상위 {@code endpoint}를 먼저 읽고 없으면 {@code _meta.endpoint}를 사용합니다. 상대 경로는 - * 포털 registry의 {@code serviceDomain} 뒤에 붙이고, 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용합니다. endpoint가 비어 있거나 HTTP(S)가 아니면 bundle - * 전체를 거부합니다. + * Tool Service manifest가 제공한 Tool 실행 endpoint를 실제 호출 URL로 확정합니다. 최상위 {@code endpoint}를 먼저 읽고 없으면 {@code _meta.endpoint}를 사용합니다. 상대 경로는 포털 registry의 {@code serviceDomain} 뒤에 붙이고, 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용합니다. endpoint가 비어 있거나 HTTP(S)가 아니면 bundle 전체를 거부합니다. + * + * @param bundle 입력값입니다. + * @param tool Tool 처리 정보입니다. + * @param meta 입력값입니다. + * @param toolName 대상 이름입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String resolveManifestEndpoint(Bundle bundle, JsonNode tool, JsonNode meta, String toolName) { String declared = tool.path("endpoint").asText(null); @@ -311,8 +359,12 @@ public class ToolBundleDiscovery { } /** - * endpoint 문자열을 URI로 해석하고, Tool 실행에 사용할 수 있는 HTTP(S) 주소 또는 상대 경로인지 확인합니다. 잘못된 URI는 discovery 실패로 격리되어 직전 정상 snapshot을 - * 유지하게 됩니다. + * endpoint 문자열을 URI로 해석하고, Tool 실행에 사용할 수 있는 HTTP(S) 주소 또는 상대 경로인지 확인합니다. 잘못된 URI는 discovery 실패로 격리되어 직전 정상 snapshot을 유지하게 됩니다. + * + * @param endpoint 처리할 값입니다. + * @param fieldName 대상 이름입니다. + * @param toolName 대상 이름입니다. + * @return 처리 결과를 반환합니다. */ private URI endpointUri(String endpoint, String fieldName, String toolName) { try { @@ -335,6 +387,9 @@ public class ToolBundleDiscovery { /** * Tool이 선언한 timeout을 설정 상한으로 절삭해, 한 Tool이 요청 예산 전체를 소모하지 못하게 합니다. + * + * @param meta 입력값입니다. + * @return 계산된 숫자 값을 반환합니다. */ private int clampTimeout(JsonNode meta) { int max = properties.discovery().maxToolTimeoutMillis(); @@ -349,6 +404,9 @@ public class ToolBundleDiscovery { /** * 매니페스트의 공개 Tool 정의를 복사하고 내부 실행 정보인 {@code _meta}와 {@code endpoint}를 제거해 tools/list 노출본을 만듭니다. + * + * @param tool Tool 처리 정보입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode publicDefinition(JsonNode tool) { ObjectNode definition = ((ObjectNode) tool).deepCopy(); @@ -359,6 +417,8 @@ public class ToolBundleDiscovery { /** * 설정에 선언된 모든 bundle의 현재 조회 상태를 반환합니다. 한 번도 조회에 성공하지 못한 bundle도 포함하므로, 설정의 기대값과 대조해 누락을 감지할 수 있습니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List statuses() { return properties.bundles().stream() @@ -375,9 +435,6 @@ public class ToolBundleDiscovery { */ public record BundleResult(String bundleId, boolean usableSnapshot, List tools) { - /** - * 노출할 Tool을 immutable copy로 고정합니다. - */ public BundleResult { tools = tools == null ? List.of() : List.copyOf(tools); } @@ -398,6 +455,9 @@ public class ToolBundleDiscovery { /** * 설정에는 있으나 아직 한 번도 조회를 시도하지 않은 bundle의 상태를 만듭니다. 꺼 둔 bundle과 조회에 실패한 bundle은 원인이 다르므로 상태 문자열로 구분합니다. + * + * @param bundle 입력값입니다. + * @return 처리 결과를 반환합니다. */ static BundleStatus never(Bundle bundle) { String status = bundle.enabled() ? "unreachable" : "disabled"; @@ -405,9 +465,6 @@ public class ToolBundleDiscovery { } } - /** - * bundle 하나의 마지막 성공 결과와 연속 실패 횟수를 보관하는 가변 상태입니다. 여러 조회 주기가 겹칠 수 있으므로 모든 갱신을 synchronized로 직렬화합니다. - */ private static final class BundleState { private List lastGood; @@ -419,6 +476,11 @@ public class ToolBundleDiscovery { /** * 조회 성공 결과를 채택하고 실패 상태를 모두 초기화합니다. + * + * @param bundleId 입력값입니다. + * @param tools 처리 대상 목록입니다. + * @param revision 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ synchronized BundleResult recordSuccess( String bundleId, List tools, String revision) { @@ -433,6 +495,12 @@ public class ToolBundleDiscovery { /** * 최초 원격 조회 실패 후 local fallback을 사용 가능한 snapshot으로 채택합니다. 상태는 healthy로 위장하지 않고 fallback으로 남겨 운영자가 원격 원천 장애를 구분할 수 있게 합니다. + * + * @param bundleId 입력값입니다. + * @param tools 처리 대상 목록입니다. + * @param revision 처리할 값입니다. + * @param failureReason 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ synchronized BundleResult recordFallback( String bundleId, List tools, String revision, String failureReason) { @@ -447,6 +515,8 @@ public class ToolBundleDiscovery { /** * usable snapshot이 이미 있는지 동기화해 확인합니다. 이 값은 local fallback을 최초 기동에만 제한하는 기준이며, 일반 조회 상태를 바꾸지 않습니다. + * + * @return 조건 충족 여부를 반환합니다. */ synchronized boolean hasSnapshot() { return lastGood != null; @@ -454,6 +524,10 @@ public class ToolBundleDiscovery { /** * 실패 횟수와 원인만 갱신하고 직전 성공본은 계속 노출합니다. 통신 실패만으로 Tool을 제거하지 않으며, 제거는 이후 정상 manifest에서 확인될 때만 반영합니다. + * + * @param bundleId 입력값입니다. + * @param reason 처리할 값입니다. + * @return 처리 결과를 반환합니다. */ synchronized BundleResult recordFailure(String bundleId, String reason) { consecutiveFailures++; @@ -463,6 +537,9 @@ public class ToolBundleDiscovery { /** * 현재 보관 중인 상태를 Actuator 조회용 요약으로 변환합니다. + * + * @param bundle 입력값입니다. + * @return 처리 결과를 반환합니다. */ synchronized BundleStatus toStatus(Bundle bundle) { String status; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java index ea622a9..cf0d507 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java @@ -15,9 +15,19 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; /** - * 여러 Tool Service bundle의 조회 결과를 하나의 Tool 목록으로 병합하는 Registry 원천 adapter입니다. {@link ToolRegistryService}의 배경 갱신에서만 호출되며 요청 경로에는 관여하지 않습니다. 병합은 - * {@code (bundleId, name)} 오름차순 정렬로 마무리해 동시 조회의 응답 순서가 {@code tools/list} 순서를 바꾸지 않게 합니다. 주요 의존성은 bundle별 조회·검증·상태를 담당하는 {@link ToolBundleDiscovery}와 상한 - * 설정입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolBundleRegistryClient + * @description 여러 Tool Service bundle의 조회 결과를 하나의 Tool 목록으로 병합하는 Registry 원천 adapter입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component @ConditionalOnExpression("${mcp.discovery.enabled:false} && !${mcp.portal.enabled:false}") @@ -28,6 +38,9 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { /** * bundle 조회 구성요소와 병합 상한 설정을 주입받습니다. + * + * @param discovery 협력 객체입니다. + * @param properties MCP 설정 정보입니다. */ public ToolBundleRegistryClient(ToolBundleDiscovery discovery, McpProperties properties) { this.discovery = discovery; @@ -35,9 +48,10 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { } /** - * 활성 bundle을 모두 조회한 뒤 사용할 수 있는 Tool snapshot만 병합해 반환합니다. - * 일부 bundle이 아직 한 번도 성공하지 못했어도 다른 bundle의 논리 MCP 동작을 막지 않으며, 모든 bundle이 사용할 수 없을 때만 Registry unavailable을 던져 - * {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다. + * 활성 bundle을 모두 조회한 뒤 사용할 수 있는 Tool snapshot만 병합해 반환합니다. 일부 bundle이 아직 한 번도 성공하지 못했어도 다른 bundle의 논리 MCP 동작을 막지 않으며, 모든 bundle이 사용할 수 없을 때만 Registry unavailable을 던져 {@link ToolRegistryService}가 기존 snapshot이나 공유 cache로 되돌아가게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @Override public List fetchTools(String routeKey) { @@ -46,8 +60,10 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { } /** - * 사용 가능한 bundle의 Tool을 이름 충돌과 총량 상한을 확인하며 하나의 정렬된 목록으로 합칩니다. - * 전부 사용할 수 없으면 실패시키고, 일부만 사용할 수 없으면 성공 bundle만 반영해 논리 MCP 간 장애 전파를 막습니다. + * 사용 가능한 bundle의 Tool을 이름 충돌과 총량 상한을 확인하며 하나의 정렬된 목록으로 합칩니다. 전부 사용할 수 없으면 실패시키고, 일부만 사용할 수 없으면 성공 bundle만 반영해 논리 MCP 간 장애 전파를 막습니다. + * + * @param results 처리 대상 목록입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List merge(List results) { List usableResults = results.stream() diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java index 1d37c19..6a331ae 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolListChangedEvent.java @@ -3,22 +3,30 @@ package io.shinhanlife.dat.biz.mcp.registry; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcNotification; /** - * route별 Tool catalog snapshot이 실제로 변경됐음을 transport 계층에 전달하는 내부 도메인 이벤트입니다. - * Registry refresh 배경 처리에서 발행되며 직접 Agent Builder 요청을 처리하지 않습니다. 이벤트 소비자는 route별 연결 상태를 - * 알고 있는 SSE/Streamable HTTP 전송 계층이며, payload는 Agent Builder로 보낼 표준 JSON-RPC notification입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolListChangedEvent + * @description route별 Tool catalog snapshot이 실제로 변경됐음을 transport 계층에 전달하는 내부 도메인 이벤트입니다. + * @author j.h.w + * @create 2026.08.11 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.11   j.h.w         최초생성
+ *
+ * 
*/ public record ToolListChangedEvent(String routeKey, JsonRpcNotification notification) { - /** - * 변경된 route key와 표준 {@code notifications/tools/list_changed} envelope를 묶습니다. - * route key는 전송 계층이 같은 route로 initialize한 Agent Builder 연결만 골라 알릴 때 사용합니다. - */ public ToolListChangedEvent { } /** - * route별 Tool 목록 변경 이벤트를 생성합니다. - * notification 본문에는 route를 넣지 않고, 표준 MCP method만 담아 Agent Builder가 다시 {@code tools/list}를 호출하게 합니다. + * route별 Tool 목록 변경 이벤트를 생성합니다. notification 본문에는 route를 넣지 않고, 표준 MCP method만 담아 Agent Builder가 다시 {@code tools/list}를 호출하게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public static ToolListChangedEvent forRoute(String routeKey) { return new ToolListChangedEvent(routeKey, JsonRpcNotification.toolsListChanged()); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java index 3959739..8b02f88 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java @@ -4,8 +4,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.JsonNode; /** - * 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고, - * {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolMetadata + * @description 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @JsonIgnoreProperties(ignoreUnknown = true) public record ToolMetadata( @@ -19,6 +30,18 @@ public record ToolMetadata( JsonNode publicDefinition, boolean exactEndpoint) { + /** + * 입력값을 내부 처리 형식으로 변환합니다. + * + * @param name 대상 이름입니다. + * @param version Tool 버전입니다. + * @param description Tool 설명입니다. + * @param endpoint 실행 endpoint입니다. + * @param inputSchema 입력 JSON Schema입니다. + * @param timeoutMillis Tool timeout 밀리초입니다. + * @param enabled 활성화 여부입니다. + * @param publicDefinition tools/list에 노출할 공개 정의입니다. + */ public ToolMetadata( String name, String version, @@ -33,8 +56,27 @@ public record ToolMetadata( /** * Tool별 timeout이 설정되어 있으면 사용하고, 없으면 공통 기본 timeout을 반환합니다. + * + * @param defaultTimeoutMillis 기본 timeout 밀리초입니다. + * @return 실제 Tool 호출에 사용할 timeout 밀리초를 반환합니다. */ public int effectiveTimeoutMillis(int defaultTimeoutMillis) { return timeoutMillis == null || timeoutMillis <= 0 ? defaultTimeoutMillis : timeoutMillis; } + + /** + * Tool 공개 정의의 annotations를 기준으로 MCP 기본 retry가 가능한지 판단합니다. 조회 전용이거나 멱등 Tool이면 허용하고, 파괴적 작업으로 표시된 Tool은 항상 금지합니다. + * + * @return MCP 기본 retry 허용 여부를 반환합니다. + */ + public boolean retrySafeByAnnotation() { + JsonNode annotations = publicDefinition == null ? null : publicDefinition.path("annotations"); + if (annotations == null || annotations.isMissingNode() || annotations.isNull()) { + return false; + } + boolean destructive = annotations.path("destructiveHint").asBoolean(false); + boolean readOnly = annotations.path("readOnlyHint").asBoolean(false); + boolean idempotent = annotations.path("idempotentHint").asBoolean(false); + return !destructive && (readOnly || idempotent); + } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java index d5bbc5c..d5a7555 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryClient.java @@ -4,40 +4,53 @@ import java.util.List; import java.util.Map; /** - * Tool metadata의 원천(source)을 읽는 역할입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolRegistryClient + * @description Tool metadata의 원천(source)을 읽는 역할입니다. + * @author j.h.w + * @create 2026.08.06 * - *

이 interface는 cache가 아닙니다. {@link ToolRegistryService}가 요청 경로에서는 memory snapshot을 읽고, cold - * start 또는 배경 refresh 때만 구현체를 호출합니다. local profile은 JSON 파일을, 운영 profile은 설정된 Tool Service bundle의 매니페스트 aggregate를 원천으로 사용합니다. Redis는 원천이 아니라 기동 warm start와 - * 성공 snapshot 공유에만 쓰는 선택적 cache입니다. + *

+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
  *
- * 

직접 MCP 요청을 처리하지 않는 outbound port이며, local 파일 구현과 운영 HTTP 구현을 profile에 따라 교체합니다. + *

*/ public interface ToolRegistryClient { /** * 현재 profile의 원천에서 Tool 전체 목록을 읽어 immutable 목록으로 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ List fetchTools(String routeKey); /** - * 원천이 route별 전체 Tool catalog를 한 번에 제공할 수 있으면 route key별 immutable 목록으로 반환합니다. - * 지원하지 않는 구현은 빈 Map을 반환하며, 호출자는 기존 단일 route 조회 방식으로 fallback합니다. + * 원천이 route별 전체 Tool catalog를 한 번에 제공할 수 있으면 route key별 immutable 목록으로 반환합니다. 지원하지 않는 구현은 빈 Map을 반환하며, 호출자는 기존 단일 route 조회 방식으로 fallback합니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ default Map> fetchAllTools() { return Map.of(); } /** - * Tool metadata 조회에 앞서 외부 registry의 endpoint 목록을 갱신합니다. - * 포털을 사용하지 않는 구현은 아무 작업도 하지 않으며, 호출자는 실패 시 기존 snapshot을 유지합니다. + * Tool metadata 조회에 앞서 외부 registry의 endpoint 목록을 갱신합니다. 포털을 사용하지 않는 구현은 아무 작업도 하지 않으며, 호출자는 실패 시 기존 snapshot을 유지합니다. + * + * @return 조건 충족 여부를 반환합니다. */ default boolean refreshSourceRegistry() { return false; } /** - * 요청 경로에서 받은 route key가 현재 Registry 원천이 알고 있는 route인지 확인합니다. - * 기본 구현은 route 개념이 없는 정적 Registry 구현과의 호환을 위해 허용으로 처리하며, Portal 기반 구현은 메모리에 적재된 endpoint snapshot만 조회해야 합니다. + * 요청 경로에서 받은 route key가 현재 Registry 원천이 알고 있는 route인지 확인합니다. 기본 구현은 route 개념이 없는 정적 Registry 구현과의 호환을 위해 허용으로 처리하며, Portal 기반 구현은 메모리에 적재된 endpoint snapshot만 조회해야 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ default boolean isKnownRoute(String routeKey) { return true; @@ -45,6 +58,8 @@ public interface ToolRegistryClient { /** * route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ default List fetchTools() { return fetchTools(""); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java index 5877754..3725298 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java @@ -10,8 +10,19 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** - * 시작 시점과 설정된 주기에 Tool Registry cache를 선행 갱신하는 scheduler입니다. 기동 preload는 즉시 실행하고, 여러 replica의 반복 조회 쏠림은 첫 scheduled 실행의 jitter로 분산합니다. MCP 요청을 직접 처리하지 않으며 - * {@link ToolRegistryService#refresh()}의 실패를 격리해 요청 시점의 정상 fallback을 보존합니다. 주요 의존성은 registry service와 Spring scheduling 설정입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolRegistryRefreshScheduler + * @description 시작 시점과 설정된 주기에 Tool Registry cache를 선행 갱신하는 scheduler입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class ToolRegistryRefreshScheduler { @@ -22,14 +33,17 @@ public class ToolRegistryRefreshScheduler { /** * Registry refresh를 실행할 service를 주입받습니다. + * + * @param registryService 협력 객체입니다. */ public ToolRegistryRefreshScheduler(ToolRegistryService registryService) { this.registryService = registryService; } /** - * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 - * 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다. + * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다. + * + * @return 처리 결과를 반환합니다. */ @EventListener(ApplicationReadyEvent.class) public void preload() { @@ -40,8 +54,9 @@ public class ToolRegistryRefreshScheduler { } /** - * 기동 직후 warm start와 원천 preload를 이미 시도했는지 알려 줍니다. readiness는 이 값과 {@link ToolRegistryService#hasUsableSnapshot()}을 함께 확인하므로, 실패하더라도 last-good snapshot이 있으면 - * 서비스하고 아무 snapshot도 없으면 트래픽을 받지 않습니다. + * 기동 직후 warm start와 원천 preload를 이미 시도했는지 알려 줍니다. readiness는 이 값과 {@link ToolRegistryService#hasUsableSnapshot()}을 함께 확인하므로, 실패하더라도 last-good snapshot이 있으면 서비스하고 아무 snapshot도 없으면 트래픽을 받지 않습니다. + * + * @return 조건 충족 여부를 반환합니다. */ public boolean firstAttemptCompleted() { return firstAttemptCompleted; @@ -61,6 +76,8 @@ public class ToolRegistryRefreshScheduler { /** * 설정된 간격마다 Tool Service manifest를 다시 읽어 cache snapshot을 갱신합니다. 첫 scheduled 실행에는 bounded random jitter를 더해 동시에 기동한 replica의 조회 시점을 분산합니다. + * + * @return 처리 결과를 반환합니다. */ @Scheduled( fixedDelayString = "${mcp.registry.refresh-interval-seconds:30}", @@ -74,8 +91,9 @@ public class ToolRegistryRefreshScheduler { } /** - * 설정된 간격마다 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 갱신합니다. - * manifest 조회와 snapshot 교체는 수행하지 않으며, 실패하더라도 기존 endpoint 목록과 snapshot은 유지됩니다. + * 설정된 간격마다 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 갱신합니다. manifest 조회와 snapshot 교체는 수행하지 않으며, 실패하더라도 기존 endpoint 목록과 snapshot은 유지됩니다. + * + * @return 처리 결과를 반환합니다. */ @Scheduled( fixedDelayString = "${mcp.portal.refresh-interval-seconds:300}", @@ -89,6 +107,8 @@ public class ToolRegistryRefreshScheduler { /** * refresh 실패를 로그로 격리하여 scheduler나 애플리케이션이 중단되지 않게 합니다. + * + * @param trigger 입력값입니다. */ private void safeManifestRefresh(String trigger) { try { @@ -105,6 +125,9 @@ public class ToolRegistryRefreshScheduler { /** * 포털 registry endpoint 목록 갱신 실패를 로그로 격리하여 manifest refresh와 요청 경로에 영향을 주지 않게 합니다. + * + * @param trigger 입력값입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean safePortalRefresh(String trigger) { try { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java index ceee42c..9b4a5d1 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryService.java @@ -21,9 +21,19 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; /** - * Tool Registry metadata 조회를 담당하는 서비스입니다. {@code tools/list}와 {@code tools/call} 요청 경로는 - * route별 in-memory snapshot만 읽고, 포털/Tool Service/Redis 조회는 기동 직후 또는 배경 갱신 경로에서만 수행합니다. - * 주요 협력 객체는 원천 조회 port인 {@link ToolRegistryClient}, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자입니다. + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolRegistryService + * @description Tool Registry metadata 조회를 담당하는 서비스입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Service public class ToolRegistryService implements McpRouteKeyValidator { @@ -39,18 +49,22 @@ public class ToolRegistryService implements McpRouteKeyValidator { new ConcurrentHashMap<>(); /** - * 원천 Registry와 선택적 Redis 공유 cache를 주입받습니다. - * 테스트에서 주로 사용하며 이벤트 발행자와 JSON mapper는 기본값으로 구성합니다. + * 원천 Registry와 선택적 Redis 공유 cache를 주입받습니다. 테스트에서 주로 사용하며 이벤트 발행자와 JSON mapper는 기본값으로 구성합니다. + * + * @param registryClient 협력 객체입니다. + * @param redisCache 협력 객체입니다. */ public ToolRegistryService( ToolRegistryClient registryClient, Optional redisCache) { - this(registryClient, redisCache, event -> { - }, new ObjectMapper()); + this(registryClient, redisCache, event -> { }, new ObjectMapper()); } /** - * 원천 Registry, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자를 주입받습니다. - * Spring 기동 시 호출되며, refresh 성공 후 기존 route snapshot과 달라진 경우에만 변경 이벤트를 발행합니다. + * 원천 Registry, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자를 주입받습니다. Spring 기동 시 호출되며, refresh 성공 후 기존 route snapshot과 달라진 경우에만 변경 이벤트를 발행합니다. + * + * @param registryClient 협력 객체입니다. + * @param redisCache 협력 객체입니다. + * @param eventPublisher 협력 객체입니다. */ public ToolRegistryService( ToolRegistryClient registryClient, @@ -60,8 +74,13 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, 검증 로그용 JSON 직렬화 도구를 주입받습니다. - * 운영 코드에서 사용하는 생성자이며 route별 snapshot 갱신과 Redis fallback 정책을 이 서비스 안에 모읍니다. + * 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, 검증 로그용 JSON 직렬화 도구를 주입받습니다. 운영 코드에서 사용하는 생성자이며 route별 snapshot 갱신과 Redis fallback 정책을 이 서비스 안에 모읍니다. + * + * @param registryClient 협력 객체입니다. + * @param redisCache 협력 객체입니다. + * @param eventPublisher 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @return 처리 결과를 반환합니다. */ @Autowired public ToolRegistryService( @@ -76,16 +95,19 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 기본 route의 활성 Tool 목록을 in-memory snapshot에서 읽습니다. - * 요청 경로에서는 Redis를 호출하지 않으며, snapshot이 아직 없을 때만 원천 조회로 cold start 공백을 메웁니다. + * 기본 route의 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으며, snapshot이 아직 없을 때만 원천 조회로 cold start 공백을 메웁니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List listTools() { return listTools(""); } /** - * 지정 route의 활성 Tool 목록을 in-memory snapshot에서 읽습니다. - * 해당 route snapshot이 없으면 그 route에 대해서만 Registry 원천을 한 번 조회해 memory에 적재합니다. + * 지정 route의 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 해당 route snapshot이 없으면 그 route에 대해서만 Registry 원천을 한 번 조회해 memory에 적재합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List listTools(String routeKey) { String normalizedRouteKey = normalizeRouteKey(routeKey); @@ -97,16 +119,16 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 요청 처리에 사용할 수 있는 route별 Tool snapshot이 memory에 하나라도 있는지 반환합니다. - * readiness 확인 과정에서 원격 Tool Service나 Redis를 직접 호출하지 않기 위한 상태 판단용 메서드입니다. + * 요청 처리에 사용할 수 있는 route별 Tool snapshot이 memory에 하나라도 있는지 반환합니다. readiness 확인 과정에서 원격 Tool Service나 Redis를 직접 호출하지 않기 위한 상태 판단용 메서드입니다. + * + * @return 조건 충족 여부를 반환합니다. */ public boolean hasUsableSnapshot() { return !snapshotsByRoute.isEmpty(); } /** - * 기동 직후 다른 replica가 Redis에 저장한 기본 route snapshot을 best-effort로 적재합니다. - * 이미 memory snapshot이 있거나 Redis 값이 없으면 아무 작업도 하지 않으며, 요청 경로의 정확성을 Redis에 의존하지 않습니다. + * 기동 직후 다른 replica가 Redis에 저장한 기본 route snapshot을 best-effort로 적재합니다. 이미 memory snapshot이 있거나 Redis 값이 없으면 아무 작업도 하지 않으며, 요청 경로의 정확성을 Redis에 의존하지 않습니다. */ public void warmStartFromSharedCache() { if (!snapshotsByRoute.isEmpty()) { @@ -118,16 +140,21 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 기본 route snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다. - * 첫 조회에서 찾지 못하면 stale snapshot 가능성을 고려해 원천을 한 번 refresh한 뒤 최종 판단합니다. + * 기본 route snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다. 첫 조회에서 찾지 못하면 stale snapshot 가능성을 고려해 원천을 한 번 refresh한 뒤 최종 판단합니다. + * + * @param name 대상 이름입니다. + * @return 처리 결과를 반환합니다. */ public ToolMetadata findEnabledTool(String name) { return findEnabledTool("", name); } /** - * 지정 route snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다. - * route별 cache가 오래되었을 수 있으므로 최초 miss 시 해당 route만 refresh한 뒤 Tool 없음 여부를 확정합니다. + * 지정 route snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다. route별 cache가 오래되었을 수 있으므로 최초 miss 시 해당 route만 refresh한 뒤 Tool 없음 여부를 확정합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param name 대상 이름입니다. + * @return 처리 결과를 반환합니다. */ public ToolMetadata findEnabledTool(String routeKey, String name) { String normalizedRouteKey = normalizeRouteKey(routeKey); @@ -151,16 +178,19 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 기본 route의 Registry 원천을 직접 조회해 활성 Tool snapshot을 갱신합니다. - * 조회가 성공한 경우에만 memory와 Redis를 갱신하고, 실패 시 기존 memory를 우선 유지합니다. + * 기본 route의 Registry 원천을 직접 조회해 활성 Tool snapshot을 갱신합니다. 조회가 성공한 경우에만 memory와 Redis를 갱신하고, 실패 시 기존 memory를 우선 유지합니다. + * + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List refresh() { return refresh(""); } /** - * 지정 route의 Registry 원천을 직접 조회해 route별 snapshot을 갱신합니다. - * 같은 route에 동시에 들어온 refresh는 single-flight로 묶고, 서로 다른 route는 독립적으로 갱신합니다. + * 지정 route의 Registry 원천을 직접 조회해 route별 snapshot을 갱신합니다. 같은 route에 동시에 들어온 refresh는 single-flight로 묶고, 서로 다른 route는 독립적으로 갱신합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ public List refresh(String routeKey) { String normalizedRouteKey = normalizeRouteKey(routeKey); @@ -183,8 +213,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 현재 memory에 알려진 모든 route를 주기적으로 갱신합니다. - * Registry client가 전체 route snapshot을 제공하면 한 번에 반영하고, 그렇지 않으면 기존 route별 refresh를 수행합니다. + * 현재 memory에 알려진 모든 route를 주기적으로 갱신합니다. Registry client가 전체 route snapshot을 제공하면 한 번에 반영하고, 그렇지 않으면 기존 route별 refresh를 수행합니다. */ public void refreshKnownRoutes() { Map> snapshots = registryClient.fetchAllTools(); @@ -200,16 +229,19 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다. - * Tool manifest 조회와 memory snapshot 교체는 여기서 수행하지 않고 scheduler의 별도 주기에서 처리합니다. + * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다. Tool manifest 조회와 memory snapshot 교체는 여기서 수행하지 않고 scheduler의 별도 주기에서 처리합니다. + * + * @return 조건 충족 여부를 반환합니다. */ public boolean refreshSourceRegistry() { return registryClient.refreshSourceRegistry(); } /** - * 요청 path에서 추출한 route key가 현재 Registry 원천에 등록된 route인지 확인합니다. - * 요청 처리 중 Redis나 원격 Portal을 호출하지 않고 Registry client가 보유한 메모리 상태만 조회해, 알 수 없는 route를 controller 진입 전에 거부할 수 있게 합니다. + * 요청 path에서 추출한 route key가 현재 Registry 원천에 등록된 route인지 확인합니다. 요청 처리 중 Redis나 원격 Portal을 호출하지 않고 Registry client가 보유한 메모리 상태만 조회해, 알 수 없는 route를 controller 진입 전에 거부할 수 있게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ @Override public boolean isKnownRoute(String routeKey) { @@ -217,8 +249,10 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * Tool 원천을 한 번 조회하고 성공한 활성 snapshot만 memory와 Redis에 반영합니다. - * 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 Redis last-good snapshot을 fallback으로 채택합니다. + * Tool 원천을 한 번 조회하고 성공한 활성 snapshot만 memory와 Redis에 반영합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 Redis last-good snapshot을 fallback으로 채택합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List refreshOnce(String routeKey) { try { @@ -246,8 +280,10 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다. - * 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로나 Redis 경로를 다시 건드리지 않고, 기존 snapshot과 비교해 로그와 변경 이벤트만 처리합니다. + * 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다. 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로나 Redis 경로를 다시 건드리지 않고, 기존 snapshot과 비교해 로그와 변경 이벤트만 처리합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param tools 처리 대상 목록입니다. */ private void replaceSnapshot(String routeKey, List tools) { List immutableTools = List.copyOf(tools); @@ -258,8 +294,10 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 이미 시작된 같은 route의 refresh 결과를 기다리고 원래 RuntimeException 유형을 보존해 전달합니다. - * 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 재사용하게 합니다. + * 이미 시작된 같은 route의 refresh 결과를 기다리고 원래 RuntimeException 유형을 보존해 전달합니다. 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 재사용하게 합니다. + * + * @param refresh 입력값입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List awaitRefresh(CompletableFuture> refresh) { try { @@ -273,8 +311,11 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 이름 조건으로 활성 Tool metadata를 찾습니다. - * 이름 중복은 원천 snapshot 병합 단계에서 거절되므로 여기서는 첫 일치 항목만 반환합니다. + * 이름 조건으로 활성 Tool metadata를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거절되므로 여기서는 첫 일치 항목만 반환합니다. + * + * @param tools 처리 대상 목록입니다. + * @param name 대상 이름입니다. + * @return 조회된 선택값을 반환합니다. */ private Optional match(List tools, String name) { return tools.stream() @@ -285,6 +326,9 @@ public class ToolRegistryService implements McpRouteKeyValidator { /** * 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다. + * + * @param name 대상 이름입니다. + * @return 처리 결과를 반환합니다. */ private JsonRpcException notFound(String name) { return new JsonRpcException( @@ -292,8 +336,11 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 기존 snapshot이 존재하고 새 snapshot과 다를 때만 Tool 목록 변경 이벤트를 발행합니다. - * 최초 로딩은 Agent Builder가 아직 목록을 받기 전일 수 있으므로 알림 대상에서 제외하고, 실제 교체가 발생한 refresh에만 영향을 줍니다. + * 기존 snapshot이 존재하고 새 snapshot과 다를 때만 Tool 목록 변경 이벤트를 발행합니다. 최초 로딩은 Agent Builder가 아직 목록을 받기 전일 수 있으므로 알림 대상에서 제외하고, 실제 교체가 발생한 refresh에만 영향을 줍니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param previous 입력값입니다. + * @param current 입력값입니다. */ private void publishListChangedIfNeeded( String routeKey, List previous, List current) { @@ -303,8 +350,11 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 로컬 검증을 위해 route별 in-memory snapshot이 최초 등록되거나 실제 변경될 때만 INFO 로그로 남깁니다. - * 포털 또는 Tool Service revision 변경이 memory에 반영되었는지 확인할 수 있도록 Tool metadata 전체를 기록합니다. + * 로컬 검증을 위해 route별 in-memory snapshot이 최초 등록되거나 실제 변경될 때만 INFO 로그로 남깁니다. 포털 또는 Tool Service revision 변경이 memory에 반영되었는지 확인할 수 있도록 Tool metadata 전체를 기록합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param previous 입력값입니다. + * @param current 입력값입니다. */ private void logSnapshot(String routeKey, List previous, List current) { boolean changed = previous == null || !previous.equals(current); @@ -317,8 +367,11 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 검증 로그에 사용할 route별 snapshot 내용을 JSON 문자열로 변환합니다. - * 직렬화 실패가 refresh 성공 여부에 영향을 주지 않도록 실패 시 최소 문자열 표현으로 대체합니다. + * 검증 로그에 사용할 route별 snapshot 내용을 JSON 문자열로 변환합니다. 직렬화 실패가 refresh 성공 여부에 영향을 주지 않도록 실패 시 최소 문자열 표현으로 대체합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param current 입력값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String snapshotJson(String routeKey, List current) { Map body = new LinkedHashMap<>(); @@ -335,6 +388,9 @@ public class ToolRegistryService implements McpRouteKeyValidator { /** * route key의 null과 공백을 기존 기본 route key인 빈 문자열로 정규화합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String normalizeRouteKey(String routeKey) { return routeKey == null ? "" : routeKey.trim(); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java index a5ddc6a..43de0b9 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClient.java @@ -29,9 +29,19 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; /** - * 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입니다. + * @package io.shinhanlife.dat.biz.mcp.toolclient + * @className HttpToolClient + * @description Tool Service로 HTTP 요청을 보내고 일반 JSON·text 응답을 내부 계약으로 정규화하는 outbound client입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class HttpToolClient implements ToolClient { @@ -43,8 +53,11 @@ public class HttpToolClient implements ToolClient { private final HttpClient toolHttpClient; /** - * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다. - * 생성 시점에는 외부 호출을 하지 않고, {@link #execute(ToolRequest, McpRequestContext)}에서 요청별 timeout과 표준 헤더를 조합합니다. + * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다. 생성 시점에는 외부 호출을 하지 않고, {@link #execute(ToolRequest, McpRequestContext)}에서 요청별 timeout과 표준 헤더를 조합합니다. + * + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. + * @param toolHttpClient Tool 처리 정보입니다. */ public HttpToolClient( ObjectMapper objectMapper, @@ -56,8 +69,11 @@ public class HttpToolClient implements ToolClient { } /** - * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. - * Tool 호출용 request ID를 새로 발급하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다. + * ToolRequest를 POST HTTP 요청으로 보내고 응답 body를 JsonNode로 정규화합니다. Tool 호출용 request ID를 새로 발급하며 timeout·401·403·기타 HTTP 오류를 구분한 예외로 변환합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return 처리 결과를 반환합니다. */ @Override public ToolResponse execute(ToolRequest request, McpRequestContext context) { @@ -92,15 +108,18 @@ public class HttpToolClient implements ToolClient { throw new ToolClientException( ToolClientException.Kind.TIMEOUT, "Tool timed out: " + request.toolName(), exception); } - throw executionException(request, exception); + throw new ToolClientException( + ToolClientException.Kind.NETWORK, "Tool network error: " + request.toolName(), exception); } catch (RestClientException | IllegalArgumentException exception) { throw executionException(request, exception); } } /** - * 임시 Tool 본문 로그를 보기 쉽게 출력하기 위해 JSON 값을 개행된 문자열로 변환합니다. - * 직렬화 실패 시에도 Tool 호출 결과는 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * 임시 Tool 본문 로그를 보기 쉽게 출력하기 위해 JSON 값을 개행된 문자열로 변환합니다. 직렬화 실패 시에도 Tool 호출 결과는 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String prettyJson(JsonNode value) { try { @@ -111,8 +130,11 @@ public class HttpToolClient implements ToolClient { } /** - * URI, 표준 추적 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. - * end-to-end 값인 {@code X-Guid}는 이어가고, Tool Service 요청의 단건 식별자인 {@code X-Request-Id}와 {@code X-Request-Time}은 여기서 새로 발급합니다. + * URI, 표준 추적 헤더와 JSON body를 조합해 실행 직전 POST 요청 객체를 만듭니다. end-to-end 값인 {@code X-Guid}는 이어가고, Tool Service 요청의 단건 식별자인 {@code X-Request-Id}와 {@code X-Request-Time}은 여기서 새로 발급합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return 처리 결과를 반환합니다. */ private RestClient.RequestBodySpec requestSpec(ToolRequest request, McpRequestContext context) { RestClient client = clientFor(remainingTimeoutMillis(request, context)); @@ -147,6 +169,9 @@ public class HttpToolClient implements ToolClient { /** * 공유 JDK HttpClient 위에 이번 호출의 read timeout만 적용한 경량 RestClient를 만듭니다. + * + * @param readTimeoutMillis 계산 또는 제한에 사용할 숫자 값입니다. + * @return 처리 결과를 반환합니다. */ private RestClient clientFor(int readTimeoutMillis) { JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(toolHttpClient); @@ -156,6 +181,10 @@ public class HttpToolClient implements ToolClient { /** * Tool timeout과 전체 MCP deadline 중 더 짧은 남은 시간을 실제 read timeout으로 선택합니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return 계산된 숫자 값을 반환합니다. */ private int remainingTimeoutMillis(ToolRequest request, McpRequestContext context) { long remainingMillis = context.remainingMillis(); @@ -169,8 +198,11 @@ public class HttpToolClient implements ToolClient { } /** - * 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다. - * Agent Builder가 보내지 않은 선택 표준 헤더는 Tool Service 호출에서도 제외합니다. + * 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 "null"이 전달되지 않게 합니다. Agent Builder가 보내지 않은 선택 표준 헤더는 Tool Service 호출에서도 제외합니다. + * + * @param headers 처리할 값입니다. + * @param name 대상 이름입니다. + * @param value 처리할 값입니다. */ private void set(org.springframework.http.HttpHeaders headers, String name, String value) { if (value != null) { @@ -179,8 +211,9 @@ public class HttpToolClient implements ToolClient { } /** - * 현재 MCP 서버의 IP를 Tool Service 호출자 IP 헤더에 넣기 위해 조회합니다. - * OS 조회가 실패하면 빈 값을 보내지 않고 보수적으로 {@code unknown}을 사용해 문제 위치가 드러나게 합니다. + * 현재 MCP 서버의 IP를 Tool Service 호출자 IP 헤더에 넣기 위해 조회합니다. OS 조회가 실패하면 빈 값을 보내지 않고 보수적으로 {@code unknown}을 사용해 문제 위치가 드러나게 합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String localHostAddress() { try { @@ -191,8 +224,9 @@ public class HttpToolClient implements ToolClient { } /** - * 현재 MCP 서버의 host name을 Tool Service 호출자 host 헤더에 넣기 위해 조회합니다. - * 컨테이너나 폐쇄망 설정 문제로 조회가 실패하면 {@code unknown}을 사용합니다. + * 현재 MCP 서버의 host name을 Tool Service 호출자 host 헤더에 넣기 위해 조회합니다. 컨테이너나 폐쇄망 설정 문제로 조회가 실패하면 {@code unknown}을 사용합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String localHostName() { try { @@ -204,6 +238,10 @@ public class HttpToolClient implements ToolClient { /** * upstream HTTP 상태를 권한 오류 또는 일반 실행 오류 ToolClientException으로 변환합니다. + * + * @param status 계산 또는 제한에 사용할 숫자 값입니다. + * @param toolName 대상 이름입니다. + * @return 처리 결과를 반환합니다. */ private ToolClientException statusException(int status, String toolName) { ToolClientException.Kind kind = @@ -217,6 +255,10 @@ public class HttpToolClient implements ToolClient { /** * 네트워크·직렬화 등 일반 client 예외를 Tool 이름이 포함된 실행 실패로 감쌉니다. + * + * @param request 처리할 요청 정보입니다. + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ private ToolClientException executionException(ToolRequest request, Exception exception) { return new ToolClientException( @@ -225,6 +267,9 @@ public class HttpToolClient implements ToolClient { /** * 예외 cause chain 전체를 따라가며 실제 socket timeout이 포함되어 있는지 확인합니다. + * + * @param throwable 처리 중 발생한 예외 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean hasTimeoutCause(Throwable throwable) { Throwable current = throwable; @@ -238,8 +283,11 @@ public class HttpToolClient implements ToolClient { } /** - * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. - * JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다. + * Content-Type이 JSON이면 body를 JSON으로 파싱하고 그 외에는 text로 보존합니다. JSON이라고 표시됐지만 파싱에 실패한 경우에도 응답을 잃지 않고 text로 반환합니다. + * + * @param body 처리할 값입니다. + * @param contentType 입력값입니다. + * @return 처리된 JSON 값을 반환합니다. */ private JsonNode parseResponse(String body, MediaType contentType) { if (body == null) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java index f47e37e..e23940e 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/toolclient/ToolClient.java @@ -2,23 +2,70 @@ package io.shinhanlife.dat.biz.mcp.toolclient; import com.fasterxml.jackson.databind.JsonNode; import io.shinhanlife.dat.biz.mcp.context.McpRequestContext; +import java.util.List; /** - * 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. {@link io.shinhanlife.dat.biz.mcp.execute.ToolExecutionService}가 이 계약에 의존하며, 구현체는 HTTP·오류 종류를 표준화해 - * 반환합니다. + * @package io.shinhanlife.dat.biz.mcp.toolclient + * @className ToolClient + * @description 실제 Tool Service 호출을 실행 계층에서 분리하기 위한 outbound port입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ public interface ToolClient { /** * ToolRequest를 한 번 실행하고 HTTP 상태와 응답 data를 반환하는 기본 Tool 호출 port입니다. + * + * @param request 처리할 요청 정보입니다. + * @param context 현재 MCP 요청 context입니다. + * @return Tool Service 호출 결과를 반환합니다. */ ToolResponse execute(ToolRequest request, McpRequestContext context); /** - * Tool Service로 전달할 URL, arguments, timeout을 묶는 불변 요청 값 객체입니다. + * Tool Service로 전달할 URL, arguments, timeout, retry 판단 결과를 묶는 불변 요청 값 객체입니다. */ record ToolRequest( - String toolName, String version, String endpoint, JsonNode arguments, int timeoutMillis) { + String toolName, + String version, + String endpoint, + JsonNode arguments, + int timeoutMillis, + List retryOnHttpStatus, + int maxAttempts, + long backoffMillis, + boolean retryable) { + + /** + * 기존 호출부와 테스트가 retry 정책 없이 Tool 요청을 만들 수 있도록 retry 비활성 기본값을 적용합니다. + * + * @param toolName Tool 이름입니다. + * @param version Tool 버전입니다. + * @param endpoint 실행 endpoint입니다. + * @param arguments Tool Service에 전달할 arguments입니다. + * @param timeoutMillis Tool 호출 timeout 밀리초입니다. + */ + public ToolRequest( + String toolName, String version, String endpoint, JsonNode arguments, int timeoutMillis) { + this(toolName, version, endpoint, arguments, timeoutMillis, List.of(), 1, 0, false); + } + + /** + * retry 대상 HTTP 상태 목록을 불변 목록으로 보정하고, 잘못된 시도 횟수나 대기 시간이 들어와도 최소 안전값으로 제한합니다. + */ + public ToolRequest { + retryOnHttpStatus = retryOnHttpStatus == null ? List.of() : List.copyOf(retryOnHttpStatus); + maxAttempts = Math.max(1, maxAttempts); + backoffMillis = Math.max(0, backoffMillis); + } } /** @@ -27,17 +74,14 @@ public interface ToolClient { record ToolResponse(int statusCode, JsonNode data) { } - /** - * upstream Tool 호출의 timeout·권한·일반 실행 실패를 실행 계층이 구분할 수 있게 전달하는 예외입니다. {@code ToolsCallHandler}는 이 정보를 거쳐 Tool 실패를 JSON-RPC error가 아닌 {@code result.isError}로 - * 응답합니다. - */ final class ToolClientException extends RuntimeException { /** - * 실행 계층이 timeout·권한 거부·기타 호출 실패를 서로 다른 MCP 오류 의미로 바꾸기 위한 실패 분류입니다. + * 실행 계층이 timeout·네트워크 장애·권한 거부·기타 호출 실패를 서로 다른 MCP 오류 의미로 바꾸기 위한 실패 분류입니다. */ public enum Kind { TIMEOUT, + NETWORK, UNAUTHORIZED, FORBIDDEN, EXECUTION @@ -48,15 +92,22 @@ public interface ToolClient { /** * 실패 종류, 안전한 메시지와 원인 예외를 보존합니다. + * + * @param kind 실패 분류입니다. + * @param message 안전한 오류 메시지입니다. + * @param cause 처리 중 발생한 예외 정보입니다. */ public ToolClientException(Kind kind, String message, Throwable cause) { this(kind, message, cause, null); } /** - * Tool Service가 반환한 HTTP 상태를 함께 보존하는 실행 예외를 만듭니다. - * 상태값은 stale snapshot 감지처럼 HTTP 의미가 필요한 후속 보정 로직에서만 사용하며, - * 일반 timeout·네트워크 장애에는 {@code null}로 둡니다. + * Tool Service가 반환한 HTTP 상태를 함께 보존하는 실행 예외를 만듭니다. 상태값은 stale snapshot 감지처럼 HTTP 의미가 필요한 후속 보정 로직에서만 사용하며, 일반 timeout·네트워크 장애에는 {@code null}로 둡니다. + * + * @param kind 실패 분류입니다. + * @param message 안전한 오류 메시지입니다. + * @param cause 처리 중 발생한 예외 정보입니다. + * @param httpStatusCode upstream HTTP 상태값입니다. */ public ToolClientException(Kind kind, String message, Throwable cause, Integer httpStatusCode) { super(message, cause); @@ -65,15 +116,18 @@ public interface ToolClient { } /** - * timeout·권한·일반 실행 중 어떤 종류의 실패인지 반환합니다. + * timeout·네트워크 장애·권한·일반 실행 중 어떤 종류의 실패인지 반환합니다. + * + * @return Tool 호출 실패 분류를 반환합니다. */ public Kind kind() { return kind; } /** - * upstream Tool Service가 실제로 반환한 HTTP 상태를 반환합니다. - * 상태 기반 복구 판단이 필요한 경우에만 값이 있으며, client 내부 장애나 timeout에는 비어 있습니다. + * upstream Tool Service가 실제로 반환한 HTTP 상태를 반환합니다. 상태 기반 복구 판단이 필요한 경우에만 값이 있으며, client 내부 장애나 timeout에는 비어 있습니다. + * + * @return upstream HTTP 상태 선택값을 반환합니다. */ public java.util.OptionalInt httpStatusCode() { return httpStatusCode == null ? java.util.OptionalInt.empty() : java.util.OptionalInt.of(httpStatusCode); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java index 1dfabfb..e8b9e60 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/CachedBodyHttpServletRequest.java @@ -13,25 +13,24 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; /** - * 설정된 MCP endpoint의 요청 본문을 제한된 크기로 메모리에 복사하고 filter와 controller가 각각 다시 읽게 하는 servlet request wrapper입니다. {@link McpExchangeFilter}가 method 확인과 입력 크기 제한을 위해 만들며, 본문이 - * 한도를 넘으면 controller까지 전달하지 않고 차단합니다. 주요 의존성은 Servlet request/stream API뿐이며, JSON-RPC method의 관찰용 해석은 {@link McpExchangeFilter}가 담당합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className CachedBodyHttpServletRequest + * @description 설정된 MCP endpoint의 요청 본문을 제한된 크기로 메모리에 복사하고 filter와 controller가 각각 다시 읽게 하는 servlet request wrapper입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { - /** - * 읽어 둔 요청 본문. 이 클래스 밖으로 배열 자체를 넘기지 않고 스트림으로만 노출합니다. - */ private final byte[] body; - /** - * 원본 요청 본문을 설정된 최대 크기까지만 메모리에 읽어 둡니다. - * - *

한도보다 1 byte 더 읽는 이유는, 전체를 다 읽어 본 뒤에 크기를 재면 거대한 요청이 이미 메모리에 올라온 뒤이기 때문입니다. 한도+1을 읽어 그 - * 길이가 한도를 넘으면 나머지는 읽지 않고 바로 차단합니다. - * - * @param maxBodyBytes 허용할 본문 최대 byte 수 - * @throws RequestBodyTooLargeException 본문이 한도를 넘어 controller까지 보내지 않고 끊을 때 - */ CachedBodyHttpServletRequest(HttpServletRequest request, int maxBodyBytes) throws IOException { super(request); byte[] candidate = request.getInputStream().readNBytes(maxBodyBytes + 1); @@ -42,37 +41,51 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { } /** - * 요청 본문을 읽을 수 있는 스트림을 매번 새로 만들어 돌려줍니다. + * 요청 본문을 읽을 수 있는 스트림을 매번 새로 만들어 돌려줍니다.

이 wrapper가 존재하는 이유가 여기에 있습니다. 원래 HTTP 요청 본문은 네트워크에서 흘러오는 스트림이라 한 번 읽으면 끝입니다. 그런데 이 서버는 같은 본문을 두 번 봐야 합니다. filter가 로그·검증용으로 JSON-RPC {@code method}를 먼저 읽고, 그 다음 controller가 전체를 다시 읽어 파싱합니다. 미리 byte 배열에 담아 두고 요청할 때마다 그 배열 위에 새 스트림을 얹어 주면 두 번 읽어도 문제가 없습니다. * - *

이 wrapper가 존재하는 이유가 여기에 있습니다. 원래 HTTP 요청 본문은 네트워크에서 흘러오는 스트림이라 한 번 읽으면 끝입니다. 그런데 이 - * 서버는 같은 본문을 두 번 봐야 합니다. filter가 로그·검증용으로 JSON-RPC {@code method}를 먼저 읽고, 그 다음 controller가 전체를 다시 읽어 파싱합니다. 미리 byte 배열에 담아 두고 요청할 때마다 그 배열 위에 새 스트림을 얹어 주면 - * 두 번 읽어도 문제가 없습니다. + * @return 처리 결과를 반환합니다. */ @Override public ServletInputStream getInputStream() { ByteArrayInputStream input = new ByteArrayInputStream(body); return new ServletInputStream() { /** 한 byte를 읽어 반환합니다. 더 읽을 것이 없으면 {@code -1}입니다. */ + /** + * 필요한 정보를 조회합니다. + * + * @return 계산된 숫자 값을 반환합니다. + */ @Override public int read() { return input.read(); } /** 본문을 끝까지 읽었는지 알려 줍니다. 메모리 배열이라 남은 byte 수로 바로 판단합니다. */ + /** + * 조건 충족 여부를 확인합니다. + * + * @return 조건 충족 여부를 반환합니다. + */ @Override public boolean isFinished() { return input.available() == 0; } /** 지금 바로 읽어도 되는지 알려 줍니다. 네트워크가 아니라 이미 메모리에 있는 데이터이므로 기다릴 일이 없어 항상 {@code true}입니다. */ + /** + * 조건 충족 여부를 확인합니다. + * + * @return 조건 충족 여부를 반환합니다. + */ @Override public boolean isReady() { return true; } /** - * 비동기(non-blocking) 읽기 콜백 등록을 거부합니다. 이 서버는 요청을 동기로만 처리하므로, 누군가 비동기로 읽으려 하면 조용히 동작하는 대신 즉시 예외를 - * 던져 잘못된 사용을 드러냅니다. + * 비동기(non-blocking) 읽기 콜백 등록을 거부합니다. 이 서버는 요청을 동기로만 처리하므로, 누군가 비동기로 읽으려 하면 조용히 동작하는 대신 즉시 예외를 던져 잘못된 사용을 드러냅니다. + * + * @param readListener 입력값입니다. */ @Override public void setReadListener(ReadListener readListener) { @@ -82,17 +95,20 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { } /** - * 요청의 문자 인코딩에 맞는 Reader를 반환합니다. 문자 인코딩이 없으면 JSON의 기본 인코딩인 UTF-8을 사용합니다. - */ - /** - * 캐시된 요청 본문을 payload 로그용 문자열로 반환합니다. - * 이미 메모리에 보관된 byte 배열만 읽으므로 controller가 다시 본문을 읽는 흐름에는 영향을 주지 않습니다. + * 캐시된 요청 본문을 payload 로그용 문자열로 반환합니다. 이미 메모리에 보관된 byte 배열만 읽으므로 controller가 다시 본문을 읽는 흐름에는 영향을 주지 않습니다. + * + * @return 처리된 문자열 값을 반환합니다. */ String bodyText() { String encoding = getCharacterEncoding(); Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding); return new String(body, charset); } + /** + * 필요한 정보를 조회합니다. + * + * @return 처리 결과를 반환합니다. + */ @Override public BufferedReader getReader() { String encoding = getCharacterEncoding(); @@ -100,14 +116,12 @@ final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { return new BufferedReader(new InputStreamReader(getInputStream(), charset)); } - /** - * 요청 본문이 설정된 한도를 넘었음을 알리는 내부 전용 예외입니다. {@link McpExchangeFilter}가 이 예외를 잡아 JSON-RPC {@code -32600 Invalid Request}로 바꾸며, controller까지 요청이 전달되지 않습니다. 이 클래스 - * 밖에서는 만들 수 없습니다. - */ static final class RequestBodyTooLargeException extends IOException { /** * 한도 값을 메시지에 담아, 로그만 보고도 어떤 설정 때문에 막혔는지 알 수 있게 합니다. + * + * @param maxBodyBytes 처리할 값입니다. */ private RequestBodyTooLargeException(int maxBodyBytes) { super("MCP request body exceeds configured maximum of " + maxBodyBytes + " bytes"); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java index bc6cd1e..6401689 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpController.java @@ -21,8 +21,19 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** - * 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. JSON-RPC 요청을 parser로 검증하고 handler로 dispatch하며, notification HTTP 202, initialize 세션 correlation - * 헤더, JSON 응답을 조립합니다. 주요 의존성은 endpoint 설정, request parser, handler registry와 request context입니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpController + * @description 외부 AgentBuilder가 배포 설정에 등록한 단일 MCP HTTP 경로를 rewrite 없이 처리하는 controller입니다. + * @author j.h.w + * @create 2026.08.06 + * + *

+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @RestController public class McpController { @@ -37,6 +48,10 @@ public class McpController { /** * JSON-RPC 변환과 method dispatch 협력 객체를 주입받습니다. Controller는 실행 규칙을 직접 구현하지 않고 각 책임 객체를 올바른 순서로 연결합니다. + * + * @param requestParser 처리할 요청 정보입니다. + * @param handlerRegistry 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. */ public McpController( JsonRpcRequestParser requestParser, McpMethodHandlerRegistry handlerRegistry, ObjectMapper objectMapper) { @@ -46,9 +61,10 @@ public class McpController { } /** - * 배포별 단일 MCP POST 요청을 받아 JSON-RPC 변환 후 알맞은 handler로 전달합니다. initialize에는 새 correlation header를 발급하고 notification은 HTTP 202, 일반 요청은 HTTP 200으로 응답합니다. mapping의 - * {@code text/event-stream}은 기존 Agent Builder Accept header를 수용하기 위한 media type일 뿐이며, 이 method는 streaming body를 만들지 않고 항상 단일 JSON 또는 빈 notification 응답을 - * 반환합니다. + * 배포별 단일 MCP POST 요청을 받아 JSON-RPC 변환 후 알맞은 handler로 전달합니다. initialize에는 새 correlation header를 발급하고 notification은 HTTP 202, 일반 요청은 HTTP 200으로 응답합니다. mapping의 {@code text/event-stream}은 기존 Agent Builder Accept header를 수용하기 위한 media type일 뿐이며, 이 method는 streaming body를 만들지 않고 항상 단일 JSON 또는 빈 notification 응답을 반환합니다. + * + * @param envelope 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ @PostMapping( value = {"${mcp.endpoint-path:/mcp}", "${mcp.endpoint-path:/mcp}/{routeKey}"}, @@ -91,8 +107,10 @@ public class McpController { } /** - * 임시 응답 본문 로그를 위해 객체를 JSON 문자열로 직렬화합니다. - * 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * 임시 응답 본문 로그를 위해 객체를 JSON 문자열로 직렬화합니다. 직렬화 실패 시에도 실제 응답 흐름은 바꾸지 않고 문자열 변환 결과만 로그에 남깁니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String toJson(Object value) { try { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java index 527c75a..14e7a3b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExceptionHandler.java @@ -17,10 +17,19 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; /** - * {@link McpController} 처리 중 발생한 예외를 AgentBuilder가 해석할 JSON-RPC 오류 응답으로 정규화하는 전용 예외 처리기입니다. 설정된 MCP endpoint의 malformed JSON, 검증 오류, 예상치 못한 controller 오류를 HTTP 200 - * 안의 JSON-RPC error envelope로 반환합니다. Filter 단계의 크기·헤더·protocol 오류는 MVC에 도달하지 않으므로 {@link McpExchangeFilter}가 직접 응답합니다. 다만 지원하지 않는 HTTP method는 JSON-RPC 이전의 - * transport 문제이므로 표준 HTTP 405로 응답합니다. 주요 의존성은 오류 코드 factory와 {@link TraceLogger}이며, Tool 실행 실패의 {@code result.isError} 변환은 이 클래스가 아니라 {@code ToolsCallHandler}가 - * 담당합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpExceptionHandler + * @description {@link McpController} 처리 중 발생한 예외를 AgentBuilder가 해석할 JSON-RPC 오류 응답으로 정규화하는 전용 예외 처리기입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @RestControllerAdvice(assignableTypes = McpController.class) public class McpExceptionHandler { @@ -29,6 +38,8 @@ public class McpExceptionHandler { /** * 모든 오류를 같은 trace 형식으로 기록하기 위해 logger를 주입받습니다. + * + * @param traceLogger 협력 객체입니다. */ public McpExceptionHandler(TraceLogger traceLogger) { this.traceLogger = traceLogger; @@ -36,6 +47,9 @@ public class McpExceptionHandler { /** * 서버가 의도적으로 발생시킨 JSON-RPC 예외를 HTTP 200의 표준 실패 응답으로 변환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ @ExceptionHandler(JsonRpcException.class) public ResponseEntity handleJsonRpcException(JsonRpcException exception) { @@ -47,6 +61,9 @@ public class McpExceptionHandler { /** * JSON 문법이 잘못되어 body를 읽지 못한 경우 Parse error 응답을 반환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleParseError( @@ -58,8 +75,10 @@ public class McpExceptionHandler { } /** - * 설정된 MCP endpoint가 허용하지 않는 HTTP method 요청을 JSON-RPC 오류가 아니라 표준 HTTP 405로 반환합니다. MCP 클라이언트는 server-push 수신용 GET이나 세션 종료용 DELETE를 시도할 수 - * 있는데, 이 서버는 POST 단일 경로만 제공하므로 지원 method를 {@code Allow} 헤더로 알려 클라이언트가 재시도하지 않게 합니다. 이 응답은 JSON-RPC 이전 단계의 transport 계약이므로 본문 없이 상태 코드와 헤더만 반환합니다. + * 설정된 MCP endpoint가 허용하지 않는 HTTP method 요청을 JSON-RPC 오류가 아니라 표준 HTTP 405로 반환합니다. MCP 클라이언트는 server-push 수신용 GET이나 세션 종료용 DELETE를 시도할 수 있는데, 이 서버는 POST 단일 경로만 제공하므로 지원 method를 {@code Allow} 헤더로 알려 클라이언트가 재시도하지 않게 합니다. 이 응답은 JSON-RPC 이전 단계의 transport 계약이므로 본문 없이 상태 코드와 헤더만 반환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleMethodNotAllowed( @@ -76,6 +95,9 @@ public class McpExceptionHandler { /** * 예상하지 못한 예외의 내부 내용을 숨기고 안전한 Internal error 응답으로 변환합니다. + * + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 처리 결과를 반환합니다. */ @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpected(Exception exception) { diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java index c32eb50..9c4bfa7 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java @@ -30,10 +30,19 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * 배포 설정의 단일 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}입니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpExchangeFilter + * @description 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 표준 Servlet 필터입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component @Order(Ordered.HIGHEST_PRECEDENCE + 10) @@ -50,8 +59,14 @@ public class McpExchangeFilter implements Filter { private final McpRouteKeyValidator routeKeyValidator; /** - * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. - * 생성 시점에는 외부 시스템을 호출하지 않으며, 실제 MCP 요청이 들어왔을 때만 context와 검증 흐름을 시작합니다. + * 요청 correlation, 최소 JSON 관찰, 경계 로그와 protocol 검증에 필요한 객체를 주입받습니다. 생성 시점에는 외부 시스템을 호출하지 않으며, 실제 MCP 요청이 들어왔을 때만 context와 검증 흐름을 시작합니다. + * + * @param headerExtractor 처리할 값입니다. + * @param traceLogger 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. + * @param protocolVersionValidator 입력값입니다. + * @param routeKeyValidator 처리 대상 route key입니다. */ public McpExchangeFilter( McpRequestContextFactory headerExtractor, @@ -69,8 +84,11 @@ public class McpExchangeFilter implements Filter { } /** - * Servlet container가 호출하는 표준 필터 진입점입니다. - * HTTP가 아니거나 MCP endpoint 대상이 아닌 요청은 그대로 다음 filter로 넘기고, MCP 요청은 중복 실행 방어 후 내부 처리 메서드로 위임합니다. + * Servlet container가 호출하는 표준 필터 진입점입니다. HTTP가 아니거나 MCP endpoint 대상이 아닌 요청은 그대로 다음 filter로 넘기고, MCP 요청은 중복 실행 방어 후 내부 처리 메서드로 위임합니다. + * + * @param request 처리할 요청 정보입니다. + * @param response 응답에 사용할 객체입니다. + * @param filterChain 입력값입니다. */ @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) @@ -99,6 +117,9 @@ public class McpExchangeFilter implements Filter { /** * 설정된 MCP endpoint 이외의 다른 HTTP 요청은 correlation 처리와 MCP 로그 대상에서 제외합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean shouldSkip(HttpServletRequest request) { if (!"POST".equalsIgnoreCase(request.getMethod())) { @@ -114,8 +135,11 @@ public class McpExchangeFilter implements Filter { } /** - * MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다. - * 헤더 검증, route 검증, request body 재사용 wrapper, protocol version 검증을 Controller 진입 전에 수행합니다. + * MCP HTTP 요청 수명 동안 context를 설정하고 요청·응답 경계 로그를 남긴 뒤 반드시 ThreadLocal을 정리합니다. 헤더 검증, route 검증, request body 재사용 wrapper, protocol version 검증을 Controller 진입 전에 수행합니다. + * + * @param request 처리할 요청 정보입니다. + * @param response 응답에 사용할 객체입니다. + * @param filterChain 입력값입니다. */ private void doMcpFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -195,8 +219,10 @@ public class McpExchangeFilter implements Filter { } /** - * Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. - * 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다. + * Portal 모드에서 요청 route가 현재 endpoint registry snapshot에 등록되어 있는지 확인합니다. 하드코딩된 route 목록을 쓰지 않고 메모리 상태만 보며, 미등록 route는 controller와 Tool 실행 계층에 닿기 전에 invalid request로 거부합니다. + * + * @param context 현재 MCP 요청 context입니다. + * @param request 처리할 요청 정보입니다. */ private void validateKnownRoute(McpRequestContext context, HttpServletRequest request) { if (properties.portal() == null || !properties.portal().enabled() || isFixedEndpointRequest(request)) { @@ -208,8 +234,10 @@ public class McpExchangeFilter implements Filter { } /** - * 설정된 endpoint path 자체가 {@code /mcp/core}처럼 route를 이미 포함한 고정 배포인지 확인합니다. - * 이 경우 route 선택이 동적 URL 입력에서 온 것이 아니므로 Registry 기반 동적 route 차단 대상에서 제외하고 기존 고정 endpoint 계약을 유지합니다. + * 설정된 endpoint path 자체가 {@code /mcp/core}처럼 route를 이미 포함한 고정 배포인지 확인합니다. 이 경우 route 선택이 동적 URL 입력에서 온 것이 아니므로 Registry 기반 동적 route 차단 대상에서 제외하고 기존 고정 endpoint 계약을 유지합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 조건 충족 여부를 반환합니다. */ private boolean isFixedEndpointRequest(HttpServletRequest request) { String path = request.getRequestURI(); @@ -222,8 +250,10 @@ public class McpExchangeFilter implements Filter { } /** - * 임시 요청 본문 로그를 보기 쉽게 출력하기 위해 JSON이면 개행된 문자열로 변환합니다. - * JSON 파싱이나 직렬화가 실패하면 원문 문자열을 그대로 돌려 요청 처리 흐름에는 영향을 주지 않습니다. + * 임시 요청 본문 로그를 보기 쉽게 출력하기 위해 JSON이면 개행된 문자열로 변환합니다. JSON 파싱이나 직렬화가 실패하면 원문 문자열을 그대로 돌려 요청 처리 흐름에는 영향을 주지 않습니다. + * + * @param body 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String prettyJson(String body) { try { @@ -234,8 +264,10 @@ public class McpExchangeFilter implements Filter { } /** - * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다. - * JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려주며, 실제 오류 계약은 뒤쪽 request adapter가 결정합니다. + * 경계 로그와 protocol 검증에 필요한 JSON-RPC {@code method} 이름만 미리 읽습니다. JSON이 깨져 있어도 예외를 던지지 않고 {@code null}을 돌려주며, 실제 오류 계약은 뒤쪽 request adapter가 결정합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String extractMethod(CachedBodyHttpServletRequest request) { try { @@ -248,6 +280,10 @@ public class McpExchangeFilter implements Filter { /** * 필터 단계의 JSON-RPC 오류를 현재 외부 계약인 HTTP 200 JSON error envelope로 작성합니다. + * + * @param response 응답에 사용할 객체입니다. + * @param code 입력값입니다. + * @param details 처리할 값입니다. */ private void writeJsonRpcError( HttpServletResponse response, JsonRpcErrorCode code, Object details) throws IOException { @@ -259,6 +295,10 @@ public class McpExchangeFilter implements Filter { /** * initialize 이후 protocol version 헤더 누락·불일치를 HTTP 400 transport 오류로 작성합니다. + * + * @param response 응답에 사용할 객체입니다. + * @param context 현재 MCP 요청 context입니다. + * @param exception 처리 중 발생한 예외 정보입니다. */ private void writeProtocolVersionError( HttpServletResponse response, McpRequestContext context, ProtocolVersionException exception) @@ -271,8 +311,11 @@ public class McpExchangeFilter implements Filter { } /** - * 선택 표준 헤더가 실제로 들어온 경우에만 HTTP 응답 header로 되돌려 줍니다. - * MCP는 12개 표준 헤더의 누락을 보정하거나 필수값으로 판단하지 않으므로, 값이 없으면 header 자체를 쓰지 않습니다. + * 선택 표준 헤더가 실제로 들어온 경우에만 HTTP 응답 header로 되돌려 줍니다. MCP는 12개 표준 헤더의 누락을 보정하거나 필수값으로 판단하지 않으므로, 값이 없으면 header 자체를 쓰지 않습니다. + * + * @param response 응답에 사용할 객체입니다. + * @param name 대상 이름입니다. + * @param value 처리할 값입니다. */ private void setResponseHeaderIfPresent(HttpServletResponse response, String name, String value) { if (StringUtils.hasText(value)) { @@ -281,8 +324,11 @@ public class McpExchangeFilter implements Filter { } /** - * protocol version transport 오류 body를 구성합니다. - * {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다. + * protocol version transport 오류 body를 구성합니다. {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다. + * + * @param context 현재 MCP 요청 context입니다. + * @param exception 처리 중 발생한 예외 정보입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private Map protocolVersionErrorBody(McpRequestContext context, ProtocolVersionException exception) { Map body = new LinkedHashMap<>(); @@ -297,6 +343,9 @@ public class McpExchangeFilter implements Filter { /** * 요청 시작 이후 경과 시간을 monotonic clock 기준 밀리초로 반환합니다. + * + * @param startedNanos 계산 또는 제한에 사용할 숫자 값입니다. + * @return 계산된 숫자 값을 반환합니다. */ private long elapsedMillis(long startedNanos) { return (System.nanoTime() - startedNanos) / 1_000_000L; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java index 7caa971..9840c7c 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpProtocolVersionValidator.java @@ -7,8 +7,19 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * initialize 이후 MCP 요청이 합의된 protocol version 헤더를 선언했는지 검사하는 stateless 정책 컴포넌트입니다. 설정된 MCP endpoint의 가장 앞단 filter가 호출하며 initialize 자체는 협상 단계이므로 검사 대상에서 제외합니다. 주요 - * 의존성은 지원·선호 버전 목록을 제공하는 {@link McpProperties}와 MCP SDK method 상수입니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpProtocolVersionValidator + * @description initialize 이후 MCP 요청이 합의된 protocol version 헤더를 선언했는지 검사하는 stateless 정책 컴포넌트입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class McpProtocolVersionValidator { @@ -19,6 +30,8 @@ public class McpProtocolVersionValidator { /** * 서버가 지원하는 MCP protocol version 설정을 주입받습니다. + * + * @param properties MCP 설정 정보입니다. */ public McpProtocolVersionValidator(McpProperties properties) { this.properties = properties; @@ -26,6 +39,9 @@ public class McpProtocolVersionValidator { /** * initialize 이후 요청에 MCP-Protocol-Version 헤더가 있는지, 지원 목록과 일치하는지 검사합니다. initialize 자체는 아직 version을 협상하는 단계이므로 검사하지 않습니다. + * + * @param request 처리할 요청 정보입니다. + * @param mcpMethod 처리할 값입니다. */ public void validatePostInitializeRequest(HttpServletRequest request, String mcpMethod) { if (mcpMethod == null || McpSchema.METHOD_INITIALIZE.equals(mcpMethod)) { @@ -40,13 +56,12 @@ public class McpProtocolVersionValidator { } } - /** - * protocol version 헤더 누락 또는 미지원 값을 filter가 JSON-RPC 오류로 변환하도록 전달하는 내부 예외입니다. 별도의 HTTP 응답을 만들지 않으며, 최종 응답 형식은 {@code McpExchangeFilter}의 책임입니다. - */ public static final class ProtocolVersionException extends RuntimeException { /** * 호출자에게 알려 줄 protocol version 거절 이유를 보존합니다. + * + * @param message 처리할 값입니다. */ public ProtocolVersionException(String message) { super(message); 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 index d1b33fa..a8ac358 100644 --- 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 @@ -11,10 +11,19 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. - * filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더를 선택값으로 추출합니다. - * 표준 헤더의 필수 여부나 업무 형식은 판단하지 않고, 전달된 값을 정규화해 Tool Service 호출 단계로 넘깁니다. - * route와 MCP session correlation처럼 서버 경계에 필요한 값만 이 컴포넌트에서 검증합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpRequestContextFactory + * @description 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다. + * @author j.h.w + * @create 2026.08.06 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.06   j.h.w         최초생성
+ *
+ * 
*/ @Component public class McpRequestContextFactory { @@ -38,17 +47,19 @@ public class McpRequestContextFactory { private final McpProperties properties; /** - * 요청 context 생성에 필요한 MCP 설정을 주입받습니다. - * 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path, 표준 헤더, timeout 설정을 사용합니다. + * 요청 context 생성에 필요한 MCP 설정을 주입받습니다. 생성 시점에는 외부 요청을 처리하지 않고, 이후 {@link #extract(HttpServletRequest)}에서 endpoint path, 표준 헤더, timeout 설정을 사용합니다. + * + * @param properties MCP 설정 정보입니다. */ public McpRequestContextFactory(McpProperties properties) { this.properties = properties; } /** - * HTTP 요청에서 route key와 선택 표준 헤더를 추출해 불변 context로 만듭니다. - * 12개 표준 헤더는 MCP가 필수 여부나 형식을 판단하지 않으며, 없으면 {@code null}로 둡니다. - * 이 메서드에서 차단하는 값은 route key와 MCP session correlation처럼 MCP transport 경계가 직접 소유한 값뿐입니다. + * HTTP 요청에서 route key와 선택 표준 헤더를 추출해 불변 context로 만듭니다. 12개 표준 헤더는 MCP가 필수 여부나 형식을 판단하지 않으며, 없으면 {@code null}로 둡니다. 이 메서드에서 차단하는 값은 route key와 MCP session correlation처럼 MCP transport 경계가 직접 소유한 값뿐입니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리 결과를 반환합니다. */ public McpRequestContext extract(HttpServletRequest request) { String routeKey = routeKey(request); @@ -87,8 +98,10 @@ public class McpRequestContextFactory { } /** - * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다. - * Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다. + * 요청 URI에서 {@code /mcp/{route}} 형태의 route key를 추출합니다. Portal 모드에서는 route가 없는 {@code /mcp} 호출을 기본값으로 보정하지 않고 거부하며, route 값은 안전한 식별자 문자만 허용합니다. + * + * @param request 처리할 요청 정보입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String routeKey(HttpServletRequest request) { String path = request.getRequestURI(); @@ -115,8 +128,9 @@ public class McpRequestContextFactory { } /** - * route가 생략된 요청의 처리 방식을 결정합니다. - * Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다. + * route가 생략된 요청의 처리 방식을 결정합니다. Portal 모드에서 endpoint path가 {@code /mcp/{routeKey}}이면 그 route를 사용하고, {@code /mcp}처럼 route가 전혀 없으면 JSON-RPC invalid request로 막습니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String defaultRouteKey() { String configuredRoute = configuredEndpointRouteKey(); @@ -130,8 +144,9 @@ public class McpRequestContextFactory { } /** - * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다. - * {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다. + * 설정된 endpoint path 자체가 route를 포함하는 배포인지 확인합니다. {@code /mcp/core}처럼 고정 공개 경로로 배포된 경우에는 별도 fallback 설정 없이 path의 마지막 segment를 route key로 사용합니다. + * + * @return 처리된 문자열 값을 반환합니다. */ private String configuredEndpointRouteKey() { String basePath = properties.endpointPath(); @@ -146,8 +161,11 @@ public class McpRequestContextFactory { } /** - * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다. - * 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다. + * 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다. 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다. + * + * @param value 처리할 값입니다. + * @param header 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String validatedOptional(String value, String header) { String normalized = trimToNull(value); @@ -158,8 +176,10 @@ public class McpRequestContextFactory { } /** - * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다. - * 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다. + * correlation 값이 허용 문자와 길이 규칙을 지키는지 검사합니다. 실패하면 request path 진입 전에 JSON-RPC invalid request 예외로 변환합니다. + * + * @param value 처리할 값입니다. + * @param header 처리할 값입니다. */ private void validate(String value, String header) { if (!SAFE_CORRELATION_ID.matcher(value).matches()) { @@ -170,8 +190,10 @@ public class McpRequestContextFactory { } /** - * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다. - * Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다. + * 앞뒤 공백을 제거한 값이 비어 있으면 {@code null}로 정규화합니다. Authorization과 선택 correlation header의 누락 여부를 같은 방식으로 판단하게 합니다. + * + * @param value 처리할 값입니다. + * @return 처리된 문자열 값을 반환합니다. */ private String trimToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java index e9d2a26..fe36dae 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java @@ -1,14 +1,27 @@ package io.shinhanlife.dat.biz.mcp.transport.http; /** - * MCP HTTP 경계에서 추출한 route key가 현재 서버가 처리할 수 있는 route인지 확인하는 transport 전용 port입니다. - * HTTP 요청을 직접 처리하지 않고 {@link McpExchangeFilter}가 controller 진입 전에 호출하며, 실제 판단 기준은 Registry 계층의 메모리 snapshot 구현이 제공합니다. + * @package io.shinhanlife.dat.biz.mcp.transport.http + * @className McpRouteKeyValidator + * @description MCP HTTP 경계에서 추출한 route key가 현재 서버가 처리할 수 있는 route인지 확인하는 transport 전용 port입니다. + * @author j.h.w + * @create 2026.08.19 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.08.19   j.h.w         최초생성
+ *
+ * 
*/ public interface McpRouteKeyValidator { /** - * 주어진 route key가 현재 허용 가능한지 확인합니다. - * 구현체는 요청 경로에서 원격 Portal이나 Redis를 새로 호출하지 않고, 이미 적재된 메모리 상태만 확인해야 합니다. + * 주어진 route key가 현재 허용 가능한지 확인합니다. 구현체는 요청 경로에서 원격 Portal이나 Redis를 새로 호출하지 않고, 이미 적재된 메모리 상태만 확인해야 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조건 충족 여부를 반환합니다. */ boolean isKnownRoute(String routeKey); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application-ocp.yml b/src/main/resources/application-ocp.yml deleted file mode 100644 index 3ab829f..0000000 --- a/src/main/resources/application-ocp.yml +++ /dev/null @@ -1,13 +0,0 @@ -mcp: - identity: ${MCP_IDENTITY} - discovery: - enabled: true - redis: - enabled: true - -management: - server: - port: ${MANAGEMENT_SERVER_PORT:9090} - health: - redis: - enabled: false diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 12cf8a0..955443a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -67,6 +67,17 @@ mcp: # 270s leaves a 30s margin to serialize and write the timeout response. request-deadline-millis: 270000 forward-authorization: false + retry: + enabled: true + max-attempts: 2 + backoff-millis: 200 + retry-on-http-status: + - 408 + - 429 + - 500 + - 502 + - 503 + - 504 redis: enabled: true key-prefix: axhub:mcp:tools @@ -93,7 +104,8 @@ mcp: # nothing a Tool Service returns can change where MCP sends the call. bundles: [] trace: - enabled: true # Rejects oversized MCP request bodies before controller processing. + enabled: true + # Rejects oversized MCP request bodies before controller processing. max-body-bytes: 1048576 logging: diff --git a/src/main/resources/glow/application-glow-dev.yml b/src/main/resources/glow/application-glow-dev.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow-local.yml b/src/main/resources/glow/application-glow-local.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow-prod.yml b/src/main/resources/glow/application-glow-prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow-test.yml b/src/main/resources/glow/application-glow-test.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/glow/application-glow.yml b/src/main/resources/glow/application-glow.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java index 1919cab..afaad45 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/deploy/HelmDeploymentContractTest.java @@ -296,7 +296,7 @@ class HelmDeploymentContractTest { assertThat(deployment) .contains("name: SPRING_PROFILES_ACTIVE") - .contains("value: ocp") + .contains("value: prod") .contains("SPRING_CONFIG_ADDITIONAL_LOCATION") .contains("checksum/config:") .contains("replicas: {{ $tier.replicas }}"); diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java index 22bd02c..9880c3b 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolExecutionServiceTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,6 +72,84 @@ class ToolExecutionServiceTest { verify(client).execute(request, requestContext); } + @Test + void retriesRetryableToolForTransientHttpStatus() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + 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/retryable"); + ToolRequest request = + new ToolRequest( + "customer.search", + "1.0.0", + "http://tool/retryable", + call.arguments(), + 3_000, + java.util.List.of(500), + 2, + 1, + true); + when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, requestContext)) + .thenThrow(new ToolClientException( + ToolClientException.Kind.EXECUTION, + "Tool returned HTTP 500: customer.search", + null, + 500)) + .thenReturn(new ToolResponse(200, OBJECT_MAPPER.readTree("{\"retried\":true}"))); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + var result = service.execute(call, requestContext); + + assertThat(result.data().path("retried").asBoolean()).isTrue(); + verify(client, times(2)).execute(request, requestContext); + verify(registry, never()).refresh(requestContext.routeKey()); + } + + @Test + void doesNotRetryUnsafeToolForTransientHttpStatus() throws Exception { + ToolRegistryService registry = mock(ToolRegistryService.class); + ToolArgumentValidator validator = mock(ToolArgumentValidator.class); + ToolRoutingService routing = mock(ToolRoutingService.class); + ToolClient client = mock(ToolClient.class); + McpRequestContext requestContext = context(); + ToolCall call = new ToolCall("customer.update", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + ToolMetadata metadata = tool("http://tool/unsafe"); + ToolRequest request = + new ToolRequest( + "customer.update", + "1.0.0", + "http://tool/unsafe", + call.arguments(), + 3_000, + java.util.List.of(500), + 2, + 1, + false); + when(registry.findEnabledTool(requestContext.routeKey(), call.toolName())).thenReturn(metadata); + when(routing.route(call, metadata)).thenReturn(request); + when(client.execute(request, requestContext)) + .thenThrow(new ToolClientException( + ToolClientException.Kind.EXECUTION, + "Tool returned HTTP 500: customer.update", + null, + 500)); + ToolExecutionService service = + new ToolExecutionService(registry, validator, routing, client, mock(TraceLogger.class)); + + assertThatThrownBy(() -> service.execute(call, requestContext)) + .isInstanceOf(JsonRpcException.class) + .hasMessageContaining("Tool returned HTTP 500"); + + verify(client).execute(request, requestContext); + verify(registry, never()).refresh(requestContext.routeKey()); + } + @Test void refreshesRouteWhenDeletedToolReturnsNotFound() throws Exception { ToolRegistryService registry = mock(ToolRegistryService.class); diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java index 1cb13aa..5ac8d85 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/execute/ToolRoutingServiceTest.java @@ -41,4 +41,47 @@ class ToolRoutingServiceTest { assertThat(request.endpoint()).isEqualTo("http://localhost:9090/internal/tools/customer-search"); } + + @Test + void enablesRetryForReadOnlyToolAnnotations() throws Exception { + ToolMetadata metadata = metadataWithAnnotations( + "customer.search", + "http://localhost:9090/internal/tools/customer-search", + "{\"readOnlyHint\":true,\"idempotentHint\":false,\"destructiveHint\":false}"); + ToolCall call = new ToolCall("customer.search", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + + var request = new ToolRoutingService(properties(false, false)).route(call, metadata); + + assertThat(request.retryable()).isTrue(); + assertThat(request.maxAttempts()).isEqualTo(2); + assertThat(request.backoffMillis()).isEqualTo(200); + assertThat(request.retryOnHttpStatus()).containsExactly(408, 429, 500, 502, 503, 504); + } + + @Test + void disablesRetryForDestructiveToolAnnotations() throws Exception { + ToolMetadata metadata = metadataWithAnnotations( + "customer.delete", + "http://localhost:9090/internal/tools/customer-delete", + "{\"readOnlyHint\":true,\"idempotentHint\":true,\"destructiveHint\":true}"); + ToolCall call = new ToolCall("customer.delete", OBJECT_MAPPER.readTree("{\"customerNo\":\"1\"}")); + + var request = new ToolRoutingService(properties(false, false)).route(call, metadata); + + assertThat(request.retryable()).isFalse(); + assertThat(request.maxAttempts()).isEqualTo(2); + } + + private ToolMetadata metadataWithAnnotations(String name, String endpoint, String annotations) throws Exception { + return new ToolMetadata( + name, + "1.0.0", + "tool", + endpoint, + OBJECT_MAPPER.readTree("{\"type\":\"object\"}"), + 3_000, + true, + OBJECT_MAPPER.readTree("{\"annotations\":" + annotations + "}"), + true); + } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java index bf3316a..6911227 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryWiringTest.java @@ -16,7 +16,7 @@ import org.springframework.context.ApplicationContext; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { - "spring.profiles.active=ocp", + "spring.profiles.active=prod", "mcp.identity=test-mcp", "mcp.discovery.enabled=true", "mcp.bundles[0].id=bundle-a",