diff --git a/build.gradle b/build.gradle index f6805d2..47c7641 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,7 @@ idea.plugins.path=${normalizedHome}/plugins tasks.register('ideaFormat', Exec) { description = 'Formats all Java sources with the IntelliJ IDEA project code style.' configureIdeaFormatter(delegate, false) + } tasks.register('ideaFormatCheck', Exec) { diff --git a/deploy/helm/mcp-server/templates/deployment.yaml b/deploy/helm/mcp-server/templates/deployment.yaml index 4bcd219..5e7b95d 100644 --- a/deploy/helm/mcp-server/templates/deployment.yaml +++ b/deploy/helm/mcp-server/templates/deployment.yaml @@ -48,6 +48,14 @@ spec: env: - name: SPRING_PROFILES_ACTIVE value: prod + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name # ConfigMap을 jar 안의 설정보다 우선 적용한다. - name: SPRING_CONFIG_ADDITIONAL_LOCATION value: file:/opt/app/config/ diff --git a/docs/architecture.md b/docs/architecture.md index e3c6f72..5894be7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T 8. `tools/call`은 `ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다. 9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다. 10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. manifest endpoint가 절대 HTTP(S) URL이면 Tool Server가 제공한 실행 주소 원천으로 허용하고, 상대 경로이면 Portal registry의 `serviceDomain` 뒤에 붙인다. -11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다. +11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. `X-Caller-IP`와 `X-Caller-Host`는 기동 시 Downward API의 `POD_IP`·`POD_NAME`을 우선 사용하고, 값이 없을 때만 로컬 host를 한 번 조회해 프로세스 수명 동안 재사용한다. Portal Registry와 Tool manifest 조회도 별도의 공유 JDK HTTP client를 사용한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다. 12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent`의 `result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다. 13. local과 운영 모두 같은 `name` lookup, endpoint/timeout, inputSchema validation 경로를 사용한다. 14. Agent Builder가 `Accept: application/json, text/event-stream`을 보내도 서버는 단일 `application/json` JSON-RPC response를 반환한다. filter는 status와 소요 시간을 `mcp_http_response_completed` 로그로 남기며 응답 body는 저장하지 않는다. @@ -53,7 +53,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T | `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 | | `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 | | `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 | -| `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 시 애플리케이션 생존 | +| `ToolRegistryPreloader` | `registry` | 기동 preload만 수행; 실패 시 애플리케이션 생존 | | `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 | | `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping | | `ToolRoutingService` | `execute` | 단일 POST endpoint와 timeout 확정, 기본 URI 검증 | @@ -149,6 +149,7 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read - 서버는 `mcp.protocol.supported-versions`와 `mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다. - `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=false`를 제공한다. Agent routing hint는 선택 정보이므로 Portal 또는 Tool Server 조회가 실패하면 `_meta.toolServers`만 생략하고 기본 initialize 응답은 정상 반환한다. 성공한 routing manifest는 필드 구조를 유지하면서 Jackson 전용 tree가 아닌 Map/List 기반 일반 JSON 값으로 바꿔 HTTP converter 구현과 분리한다. +- Agent routing hint는 route/bundle별 in-memory last-good snapshot으로 관리한다. ApplicationReady preload가 Portal endpoint와 Tool manifest를 확보한 뒤 모든 route의 `/tool-service-manifest`도 미리 적재하므로 최초 initialize는 원격 호출 없이 snapshot을 사용한다. `mcp.agent-routing-hints.refresh-ttl-seconds` 안의 initialize 요청도 Tool Server를 다시 호출하지 않으며, TTL 만료 후 첫 요청 또는 Portal bundle 구성 변경 시에만 갱신한다. 일부 bundle 갱신 실패는 기존 성공본을 유지하고, 한 번도 성공하지 못한 bundle만 응답에서 제외한다. - 이 서버는 stateless이므로 협상 결과를 session에 저장하지 않는다. `initialize` 이후 Agent Builder는 모든 MCP HTTP 요청에 `MCP-Protocol-Version: `을 포함해야 하며, 서버는 매 요청을 독립적으로 검증한다. - header가 누락되거나 지원하지 않는 값이면 JSON-RPC error가 아닌 HTTP `400 Bad Request`를 반환한다. 오류 body는 `error`, `message`, `supportedVersions`, `guid`를 포함해 호출자가 올바른 header를 진단할 수 있게 한다. @@ -181,7 +182,7 @@ rolling update 중 새 Pod이 빈 catalog로 기존 정상 Pod을 대체하지 | 확정 가능 | 무관 | 무관 | memory 갱신 후 Redis 저장(best-effort). **성공한 결과만 저장한다** | | 확정 불가 | hit | 무관 | 현재 memory 유지. 더 오래된 Redis 값으로 덮어쓰지 않는다 | | 확정 불가 | miss | hit | Redis의 공유 last-good snapshot으로 warm start | -| 확정 불가 | miss | miss/장애 | `-32003`을 반환하고 다음 주기에 재시도 | +| 확정 불가 | miss | miss/장애 | `-32003`을 반환하고 다음 TTL 만료 요청에서 재시도 | 각 bundle은 이번 성공본 또는 직전 성공본이 있어야 aggregate를 확정할 수 있다. 조회 실패는 Tool 삭제로 해석하지 않으며, 성공한 매니페스트에서 빠진 경우에만 삭제를 반영한다. 이름 충돌이나 총량 상한 초과도 전체 갱신 실패로 처리한다. 동시에 여러 refresh가 들어오면 single-flight로 하나의 원천 조회 결과를 공유한다. @@ -196,9 +197,9 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다. ## Portal Registry and Tool manifest refresh -로컬 검증에서는 `mcp.portal.registry-url`을 `file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 주기 endpoint refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` 주기 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다. +로컬 검증에서는 `mcp.portal.registry-url`을 `file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 요청 시점 TTL refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` TTL 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다. -Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain`과 `manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 Tool Server 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다. +Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain`과 `manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 scheduler polling 없이 요청 시점에 `mcp.portal.refresh-ttl-seconds`와 `mcp.registry.refresh-ttl-seconds`를 확인하고, 만료된 원천만 갱신한다. Portal TTL 만료 시 동시 요청이 들어와도 lock 안에서 원격 I/O를 수행하지 않는 single-flight로 Portal 조회 한 건만 실행하고 나머지 요청은 같은 결과를 공유한다. Portal route는 `enabled: "Y"`이면 활성 route로 채택하고 `"N"`이면 요청 대상에서 제외한다. 비활성화되거나 Portal 응답에서 삭제된 route는 Tool memory snapshot과 route별 TTL 상태도 즉시 제거하되, 현재 비활성 정책인 Redis에는 접근하지 않는다. 활성 route의 Tool Service는 `enabled: "Y"`인 항목만 호출하며, 모두 `"N"`이어도 route 자체는 유지하고 빈 Tool 목록을 제공한다. 활성 Tool Service의 endpoint 메타데이터가 잘못되면 해당 route의 기존 정상 endpoint snapshot을 유지하고, 최초 등록 route라면 빈 Tool 목록으로 격리한다. `routeRevision` 또는 endpoint 구성이 바뀐 route는 Tool manifest TTL이 남아 있어도 그 route만 즉시 다시 조회한다. Tool Server 내부 tool/schema/revision/endpoint 변경은 manifest TTL 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 존재하지 않는 Tool 이름이 반복 호출될 때는 route별 5초 cooldown 안에서 manifest 즉시 갱신을 한 번만 허용한다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 TTL 만료 요청에서 재시도한다. 노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta`와 `endpoint` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 top-level `endpoint` 또는 `_meta.endpoint`를 내부 실행 endpoint로 사용한다. @@ -216,7 +217,7 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server - MCP envelope/method 변경: adapter → handler registry → handler 직렬화 테스트 - `tools/call` 변경: handler params → Registry metadata → argument validator → routing → Tool client → error mapping -- Tool metadata 변경: 매니페스트 역직렬화·bundle 검증 → aggregate 확정 → memory/Redis fallback → refresh scheduler +- Tool metadata 변경: 매니페스트 역직렬화·bundle 검증 → aggregate 확정 → memory/Redis fallback → 요청 시점 TTL refresh - correlation 변경: header extractor → filter/context 정리 → response/downstream header - 공개 path/배포 변경: topology의 path 유일성 → Route host/path/Service → ConfigMap endpoint → Controller·Filter → Agent Builder 등록 URL - 최종 확인: `.\gradlew.bat clean check`, `bootJar`, 실행 JAR의 initialize → notification → tools/list 흐름 diff --git a/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md b/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md index a88a376..697d228 100644 --- a/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md +++ b/docs/contracts/tool-service-mcp/TEMP-tool-list-loading-guide.md @@ -20,7 +20,7 @@ Tool Service -- GET /tool-manifest --> MCP Server -- JSON-RPC tools/list --> Age ## 1. 최초 적재는 구현되어 있는가? -**구현되어 있다.** Spring 애플리케이션이 준비되면 `ToolRegistryRefreshScheduler.preload()`가 실행된다. +**구현되어 있다.** Spring 애플리케이션이 준비되면 `ToolRegistryPreloader.preload()`가 실행된다. ```text ApplicationReadyEvent @@ -217,7 +217,7 @@ Content-Type: application/json ## 확인한 구현·테스트 -- 최초 preload·주기 refresh: `ToolRegistryRefreshScheduler` +- 최초 preload: `ToolRegistryPreloader` - in-memory snapshot·실패 fallback: `ToolRegistryService` - HTTP 매니페스트 조회·필드 검증: `ToolBundleDiscovery` - `tools/list` 공개 필드 변환·`_meta` 제거: `ToolsListHandler` diff --git a/shl_mcp-source-no-settings-20260917.zip b/shl_mcp-source-no-settings-20260917.zip new file mode 100644 index 0000000..6c830c4 Binary files /dev/null and b/shl_mcp-source-no-settings-20260917.zip differ diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/AgentRoutingHintsProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/AgentRoutingHintsProperties.java index ea49f7c..a518ec2 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/AgentRoutingHintsProperties.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/AgentRoutingHintsProperties.java @@ -1,5 +1,6 @@ package io.shinhanlife.dat.biz.mcp.config; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -16,15 +17,21 @@ import org.springframework.validation.annotation.Validated; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.09.02 j.h.w 최초생성 + * 2026.09.16 j.h.w 요청 시점 routing hint snapshot TTL 설정 추가 * * */ @Validated @ConfigurationProperties(prefix = "mcp.agent-routing-hints") -public record AgentRoutingHintsProperties(boolean enabled, @NotBlank String manifestPath) { +public record AgentRoutingHintsProperties( + boolean enabled, + @NotBlank String manifestPath, + @Min(1) long refreshTtlSeconds) { + + private static final long DEFAULT_REFRESH_TTL_SECONDS = 300; /** - * routing manifest 경로가 생략된 설정에서도 기본 Tool Server API 경로를 사용하도록 보정합니다. 값은 항상 slash로 시작하게 만들어 service domain 뒤에 안전하게 붙일 수 있게 합니다. + * routing manifest 경로와 TTL이 생략된 설정에서도 안전한 기본값을 사용하도록 보정합니다. 경로는 항상 slash로 시작하게 만들어 service domain 뒤에 안전하게 붙일 수 있게 합니다. */ public AgentRoutingHintsProperties { if (manifestPath == null || manifestPath.isBlank()) { @@ -32,5 +39,8 @@ public record AgentRoutingHintsProperties(boolean enabled, @NotBlank String mani } else if (!manifestPath.startsWith("/")) { manifestPath = "/" + manifestPath; } + if (refreshTtlSeconds <= 0) { + refreshTtlSeconds = DEFAULT_REFRESH_TTL_SECONDS; + } } } 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 59e5b2b..7c15156 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 @@ -6,13 +6,13 @@ import java.time.Duration; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; /** * @package io.shinhanlife.dat.biz.mcp.config * @className HttpClientConfig - * @description Tool Service manifest 조회와 Tool 실행에 필요한 Spring/JDK client Bean을 구성하는 설정 클래스입니다. + * @description HTTP 요청을 직접 처리하지 않고, Tool 실행과 Portal·manifest 조회가 연결을 재사용하도록 용도별 공유 JDK HttpClient와 Spring RestClient Bean을 구성하는 설정 클래스입니다. * @author j.h.w * @create 2026.08.06 * @@ -21,6 +21,7 @@ import org.springframework.web.client.RestClient; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.17 j.h.w Portal·manifest 조회 client를 공유 JDK HttpClient 기반으로 변경 * * */ @@ -41,19 +42,36 @@ public class HttpClientConfig { .build(); } + /** + * Portal Registry와 Tool Service manifest 조회가 연결을 재사용하도록 discovery timeout을 적용한 공유 JDK HTTP client를 생성합니다. + * + * @param properties MCP discovery 설정 정보입니다. + * @return Portal·manifest 조회용 공유 HTTP client를 반환합니다. + */ + @Bean + @Qualifier("manifestHttpClient") + HttpClient manifestHttpClient(McpProperties properties) { + McpProperties.Discovery discovery = properties.discovery(); + long connectTimeoutMillis = discovery == null ? 1_000 : discovery.connectTimeoutMillis(); + return HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(connectTimeoutMillis)) + .build(); + } + /** * Portal Registry와 Tool Service manifest 조회에 사용할 RestClient를 생성합니다. * + * @param manifestHttpClient Portal·manifest 조회에서 공유할 JDK HTTP client입니다. * @param properties MCP 설정 정보입니다. * @return manifest 조회용 RestClient를 반환합니다. */ @Bean @Qualifier("manifestRestClient") - RestClient manifestRestClient(McpProperties properties) { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + RestClient manifestRestClient( + @Qualifier("manifestHttpClient") HttpClient manifestHttpClient, + McpProperties properties) { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(manifestHttpClient); McpProperties.Discovery discovery = properties.discovery(); - factory.setConnectTimeout( - Duration.ofMillis(discovery == null ? 1_000 : discovery.connectTimeoutMillis())); factory.setReadTimeout( Duration.ofMillis(discovery == null ? 3_000 : discovery.readTimeoutMillis())); return RestClient.builder().requestFactory(factory).build(); 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 56111c6..5b7ec6c 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 @@ -49,7 +49,7 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { * @param properties MCP 설정 정보입니다. */ public InitializeHandler(McpProperties properties) { - this(properties, new AgentRoutingHintsProperties(false, "/tool-service-manifest"), null, null); + this(properties, new AgentRoutingHintsProperties(false, "/tool-service-manifest", 300), null, null); } /** 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 3d32b83..46760b3 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 @@ -1,6 +1,6 @@ package io.shinhanlife.dat.biz.mcp.observability; -import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryRefreshScheduler; +import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryPreloader; import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthIndicator; @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; /** * @package io.shinhanlife.dat.biz.mcp.observability * @className ToolCatalogHealthIndicator - * @description Tool discovery 첫 시도와 in-memory snapshot 적재 여부로 readiness를 판정하는 health indicator입니다. + * @description MCP 요청을 직접 처리하지 않고 Actuator readiness 조회 시 ToolRegistryPreloader의 최초 적재 완료 여부와 ToolRegistryService의 in-memory snapshot 상태를 조합해 준비 상태를 판정합니다. * @author j.h.w * @create 2026.08.06 * @@ -24,18 +24,18 @@ import org.springframework.stereotype.Component; @Component public class ToolCatalogHealthIndicator implements HealthIndicator { - private final ToolRegistryRefreshScheduler scheduler; + private final ToolRegistryPreloader preloader; private final ToolRegistryService registryService; /** * 기동 preload 시점과 usable Tool snapshot을 함께 확인할 협력 객체를 주입받습니다. * - * @param scheduler 협력 객체입니다. + * @param preloader 기동 시 최초 Tool Registry 적재 상태를 제공하는 컴포넌트입니다. * @param registryService 협력 객체입니다. */ public ToolCatalogHealthIndicator( - ToolRegistryRefreshScheduler scheduler, ToolRegistryService registryService) { - this.scheduler = scheduler; + ToolRegistryPreloader preloader, ToolRegistryService registryService) { + this.preloader = preloader; this.registryService = registryService; } @@ -46,7 +46,7 @@ public class ToolCatalogHealthIndicator implements HealthIndicator { */ @Override public Health health() { - boolean firstAttemptCompleted = scheduler.firstAttemptCompleted(); + boolean firstAttemptCompleted = preloader.firstAttemptCompleted(); boolean usableSnapshot = registryService.hasUsableSnapshot(); Health.Builder health = firstAttemptCompleted && usableSnapshot ? Health.up() : Health.down(); return health.withDetail( 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 f38d850..57ca617 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 @@ -2,6 +2,7 @@ package io.shinhanlife.dat.biz.mcp.registry; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dat.biz.mcp.config.AgentRoutingHintsProperties; import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties.Bundle; @@ -20,8 +21,12 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.LongSupplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.io.Resource; @@ -42,6 +47,9 @@ import org.springframework.web.client.RestClient; * ---------- ---------- ---------------- * 2026.08.11 j.h.w 최초생성 * 2026.09.08 j.h.w local·dev serviceKey별 임시 manifest 원천 연결 + * 2026.09.16 j.h.w routing hint 요청 시점 TTL snapshot과 last-good fallback 추가 + * 2026.09.17 j.h.w Portal enabled 및 routeRevision 기반 변경 감지 추가 + * 2026.09.17 j.h.w 활성 route와 Tool Service 가용성 분리 및 잘못된 endpoint last-good 방어 * * */ @@ -58,10 +66,18 @@ public class PortalToolRegistryClient implements ToolRegistryClient { private final ObjectMapper objectMapper; private final ResourceLoader resourceLoader; private final LocalFixtureProperties localFixtureProperties; - private final java.util.concurrent.atomic.AtomicReference lastPortalRevision = - new java.util.concurrent.atomic.AtomicReference<>(); + private final AgentRoutingHintsProperties routingHintsProperties; + private final LongSupplier currentTimeMillis; private final java.util.concurrent.ConcurrentMap> bundlesByRoute = new java.util.concurrent.ConcurrentHashMap<>(); + private final ConcurrentMap routingHintSnapshotsByRoute = + new ConcurrentHashMap<>(); + private final ConcurrentMap routingHintRefreshLocksByRoute = + new ConcurrentHashMap<>(); + private volatile Map routeRevisionsByRoute = Map.of(); + private volatile Set changedSourceRouteKeys = Set.of(); + private volatile Set removedSourceRouteKeys = Set.of(); + private volatile boolean portalRegistryLoaded; /** * Portal Registry 조회 client와 로컬 리소스 reader, 기존 Tool Service manifest discovery를 주입받습니다. registry 위치가 HTTP(S)이면 {@link RestClient}를 사용하고, {@code file:} 또는 {@code classpath:}이면 {@link ResourceLoader}와 {@link ObjectMapper}로 읽어 동일한 endpoint snapshot 변환 경로에 전달합니다. @@ -73,7 +89,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. * @param resourceLoader 처리할 값입니다. * @param localFixtureProperties local·dev 임시 manifest 설정입니다. + * @param routingHintsProperties Agent routing hint TTL과 경로 설정입니다. */ + @Autowired public PortalToolRegistryClient( @Qualifier("manifestRestClient") RestClient restClient, McpProperties properties, @@ -81,7 +99,43 @@ public class PortalToolRegistryClient implements ToolRegistryClient { Optional redisPortalRegistryCache, ObjectMapper objectMapper, ResourceLoader resourceLoader, - LocalFixtureProperties localFixtureProperties) { + LocalFixtureProperties localFixtureProperties, + AgentRoutingHintsProperties routingHintsProperties) { + this( + restClient, + properties, + discovery, + redisPortalRegistryCache, + objectMapper, + resourceLoader, + localFixtureProperties, + routingHintsProperties, + System::currentTimeMillis); + } + + /** + * 테스트에서 시간을 직접 제어할 수 있도록 routing hint snapshot 시계를 함께 주입합니다. 운영 Bean은 공개 생성자를 통해 시스템 시계를 사용합니다. + * + * @param restClient Tool Server와 Portal HTTP 호출 client입니다. + * @param properties MCP 설정 정보입니다. + * @param discovery Tool manifest discovery 협력 객체입니다. + * @param redisPortalRegistryCache Portal registry Redis fallback입니다. + * @param objectMapper JSON 변환기입니다. + * @param resourceLoader classpath와 file registry reader입니다. + * @param localFixtureProperties local·dev fixture 설정입니다. + * @param routingHintsProperties Agent routing hint TTL과 경로 설정입니다. + * @param currentTimeMillis TTL 판정에 사용할 현재 시각 공급자입니다. + */ + PortalToolRegistryClient( + RestClient restClient, + McpProperties properties, + ToolBundleDiscovery discovery, + Optional redisPortalRegistryCache, + ObjectMapper objectMapper, + ResourceLoader resourceLoader, + LocalFixtureProperties localFixtureProperties, + AgentRoutingHintsProperties routingHintsProperties, + LongSupplier currentTimeMillis) { this.restClient = restClient; this.properties = properties; this.discovery = discovery; @@ -89,6 +143,8 @@ public class PortalToolRegistryClient implements ToolRegistryClient { this.objectMapper = objectMapper; this.resourceLoader = resourceLoader; this.localFixtureProperties = localFixtureProperties; + this.routingHintsProperties = routingHintsProperties; + this.currentTimeMillis = currentTimeMillis; } /** @@ -135,7 +191,27 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * Portal registry endpoint snapshot에 현재 route key가 존재하는지 request path에서 빠르게 확인합니다. 네트워크 조회를 새로 수행하지 않고 이미 적재된 {@code bundlesByRoute}만 읽으므로 잘못된 route가 controller와 Tool 실행 계층으로 들어가지 못하게 하는 1차 방어선입니다. + * 가장 최근 Portal 응답에서 endpoint 구성 또는 {@code routeRevision}이 변경된 route를 반환합니다. 반환값은 다음 갱신 전까지 유지되어 필터 단계에서 Portal을 먼저 갱신한 경우에도 controller 단계가 변경 신호를 놓치지 않게 합니다. + * + * @return 가장 최근 Portal 갱신에서 변경된 route key의 불변 집합입니다. + */ + @Override + public Set changedSourceRouteKeys() { + return changedSourceRouteKeys; + } + + /** + * 가장 최근 Portal registry 갱신에서 비활성화되거나 응답에서 삭제된 route를 반환합니다. Tool Registry 서비스는 이 값으로 해당 route의 Tool memory snapshot과 TTL 상태를 제거합니다. + * + * @return 가장 최근 Portal 갱신에서 제거된 route key의 불변 집합입니다. + */ + @Override + public Set removedSourceRouteKeys() { + return removedSourceRouteKeys; + } + + /** + * Portal registry endpoint snapshot에 현재 활성 route key가 존재하는지 request path에서 빠르게 확인합니다. 활성 Tool Service가 하나도 없어 빈 bundle 목록인 route도 Portal에서 활성화된 route로 인정하며, 네트워크 조회 없이 메모리 snapshot만 읽습니다. * * @param routeKey 처리 대상 route key입니다. * @return 조건 충족 여부를 반환합니다. @@ -150,7 +226,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 현재 route에 연결된 Tool Server별 routing manifest API를 호출해 원문 JSON을 모읍니다. 이 정보는 Agent의 MCP 선택 최적화에만 쓰이며, 일부 Tool Server 호출이 실패해도 initialize 응답 자체가 실패하지 않도록 성공한 응답만 반환합니다. + * 현재 route의 routing hint snapshot을 반환합니다. snapshot이 없거나 TTL이 만료되었거나 Portal의 bundle 구성이 바뀐 경우에만 Tool Server API를 다시 호출하며, 일부 호출이 실패하면 해당 bundle의 last-good 값을 유지합니다. * * @param routeKey 처리 대상 route key입니다. * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. @@ -162,11 +238,113 @@ public class PortalToolRegistryClient implements ToolRegistryClient { ensurePortalRegistryLoaded(); List bundles = bundlesByRoute.get(normalizedRouteKey); if (bundles == null || bundles.isEmpty()) { + routingHintSnapshotsByRoute.remove(normalizedRouteKey); return List.of(); } + String normalizedManifestPath = normalizePath(manifestPath); + RoutingHintSnapshot snapshot = routingHintSnapshotsByRoute.get(normalizedRouteKey); + if (isUsableRoutingHintSnapshot(snapshot, bundles, normalizedManifestPath)) { + return routingManifests(snapshot, bundles); + } + Object refreshLock = routingHintRefreshLocksByRoute.computeIfAbsent( + normalizedRouteKey, ignored -> new Object()); + synchronized (refreshLock) { + snapshot = routingHintSnapshotsByRoute.get(normalizedRouteKey); + if (isUsableRoutingHintSnapshot(snapshot, bundles, normalizedManifestPath)) { + return routingManifests(snapshot, bundles); + } + return refreshRoutingHintSnapshot( + normalizedRouteKey, bundles, normalizedManifestPath, snapshot); + } + } + + /** + * ApplicationReady preload에서 Portal이 제공한 모든 route의 routing hint snapshot을 미리 채웁니다. route별 실패는 기존 조회 메서드의 last-good 정책으로 격리하며, 기능이 꺼진 환경에서는 Tool Server를 호출하지 않습니다. + */ + @Override + public void preloadRoutingHints() { + if (!routingHintsProperties.enabled()) { + return; + } + ensurePortalRegistryLoaded(); + bundlesByRoute.keySet().stream() + .sorted() + .forEach(routeKey -> fetchRoutingManifests( + routeKey, routingHintsProperties.manifestPath())); + } + + /** + * route별 routing hint snapshot이 현재 Portal bundle 구성·API 경로와 일치하고 TTL 안에 있는지 확인합니다. bundle endpoint가 바뀌면 TTL이 남아 있어도 false를 반환해 다음 initialize에서 즉시 갱신합니다. + * + * @param snapshot 기존 route snapshot입니다. + * @param bundles Portal이 현재 제공하는 Tool Server 목록입니다. + * @param manifestPath routing hint API 경로입니다. + * @return 원격 호출 없이 snapshot을 재사용할 수 있으면 true입니다. + */ + private boolean isUsableRoutingHintSnapshot( + RoutingHintSnapshot snapshot, List bundles, String manifestPath) { + if (snapshot == null + || !snapshot.bundles().equals(bundles) + || !snapshot.manifestPath().equals(manifestPath)) { + return false; + } + long elapsedMillis = currentTimeMillis.getAsLong() - snapshot.lastAttemptMillis(); + return elapsedMillis < routingHintsProperties.refreshTtlSeconds() * 1_000L; + } + + /** + * 현재 route의 Tool Server별 routing manifest를 갱신합니다. 성공한 bundle은 새 값으로 교체하고 실패한 bundle은 기존 last-good 값을 유지하며, Portal에서 삭제된 bundle의 값은 snapshot에서 제거합니다. + * + * @param routeKey 갱신할 route key입니다. + * @param bundles Portal이 현재 제공하는 Tool Server 목록입니다. + * @param manifestPath routing hint API 경로입니다. + * @param previous 기존 route snapshot입니다. + * @return 현재 initialize 응답에 사용할 routing manifest 목록입니다. + */ + private List refreshRoutingHintSnapshot( + String routeKey, + List bundles, + String manifestPath, + RoutingHintSnapshot previous) { + Map manifestsByBundle = new LinkedHashMap<>(); + if (previous != null) { + manifestsByBundle.putAll(previous.manifestsByBundle()); + } + Set activeBundleIds = bundles.stream().map(Bundle::id).collect(java.util.stream.Collectors.toSet()); + manifestsByBundle.keySet().removeIf(bundleId -> !activeBundleIds.contains(bundleId)); + for (Bundle bundle : bundles) { + fetchRoutingManifest(bundle, manifestPath) + .ifPresent(manifest -> manifestsByBundle.put(bundle.id(), manifest.deepCopy())); + } + RoutingHintSnapshot refreshed = new RoutingHintSnapshot( + List.copyOf(bundles), + manifestPath, + Map.copyOf(manifestsByBundle), + currentTimeMillis.getAsLong()); + routingHintSnapshotsByRoute.put(routeKey, refreshed); + log.debug( + "Routing hint snapshot refreshed. routeKey={} bundleCount={} hintCount={} ttlSeconds={}", + routeKey, + bundles.size(), + manifestsByBundle.size(), + routingHintsProperties.refreshTtlSeconds()); + return routingManifests(refreshed, bundles); + } + + /** + * snapshot의 bundle별 hint를 현재 Portal bundle 순서로 복사해 initialize 응답용 불변 목록을 만듭니다. 복사본을 반환해 응답 변환 과정이 cache 원본을 변경하지 못하게 합니다. + * + * @param snapshot 사용할 route snapshot입니다. + * @param bundles Portal이 현재 제공하는 Tool Server 목록입니다. + * @return 현재 사용 가능한 routing manifest 목록입니다. + */ + private List routingManifests(RoutingHintSnapshot snapshot, List bundles) { List manifests = new ArrayList<>(); for (Bundle bundle : bundles) { - fetchRoutingManifest(bundle, manifestPath).ifPresent(manifests::add); + JsonNode manifest = snapshot.manifestsByBundle().get(bundle.id()); + if (manifest != null) { + manifests.add(manifest.deepCopy()); + } } return List.copyOf(manifests); } @@ -181,7 +359,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { try { return portalRegistry(registryUrl); } catch (RuntimeException exception) { - if (!bundlesByRoute.isEmpty()) { + if (portalRegistryLoaded) { log.warn( "Portal registry refresh failed; keeping in-memory endpoint snapshot. reason={}", exception.getClass().getSimpleName()); @@ -208,24 +386,89 @@ public class PortalToolRegistryClient implements ToolRegistryClient { if (registry == null) { return false; } - boolean changed = logPortalRegistryIfChanged(registryUrl, registry); JsonNode routes = registry.path("routes"); - if (!routes.isArray()) { - String routeKey = normalizeRouteKey(required(registry, "routeKey")); - bundlesByRoute.put(routeKey, toBundles(routeKey, registry.path("toolServices")).orElseThrow(() -> unavailable("Portal registry has no usable route: " + routeKey))); - return changed; - } Map> updated = new LinkedHashMap<>(); - for (JsonNode route : routes) { - String routeKey = normalizeRouteKey(required(route, "routeKey")); - toBundles(routeKey, route.path("toolServices")).ifPresent(bundles -> updated.put(routeKey, bundles)); - } - if (updated.isEmpty()) { - throw unavailable("Portal registry has no usable route"); + Map updatedRevisions = new LinkedHashMap<>(); + if (!routes.isArray()) { + registerPortalRoute(registry, updated, updatedRevisions); + } else { + if (routes.isEmpty()) { + throw unavailable("Portal registry has no route"); + } + for (JsonNode route : routes) { + registerPortalRoute(route, updated, updatedRevisions); + } } + Map> previousBundles = Map.copyOf(bundlesByRoute); + Map previousRevisions = routeRevisionsByRoute; + Set removedRoutes = new HashSet<>(previousBundles.keySet()); + removedRoutes.removeAll(updated.keySet()); + Set changedRoutes = changedRouteKeys( + previousBundles, previousRevisions, updated, updatedRevisions); bundlesByRoute.keySet().removeIf(routeKey -> !updated.containsKey(routeKey)); + routingHintSnapshotsByRoute.keySet().removeIf(routeKey -> !updated.containsKey(routeKey)); + routingHintRefreshLocksByRoute.keySet().removeIf(routeKey -> !updated.containsKey(routeKey)); bundlesByRoute.putAll(updated); - return changed; + routeRevisionsByRoute = Map.copyOf(updatedRevisions); + changedSourceRouteKeys = Set.copyOf(changedRoutes); + removedSourceRouteKeys = Set.copyOf(removedRoutes); + portalRegistryLoaded = true; + logPortalRegistryIfChanged(registryUrl, registry, changedRoutes); + return !changedRoutes.isEmpty(); + } + + /** + * Portal route 한 건의 사용 여부를 확인하고 활성 route의 Tool Service endpoint와 revision을 임시 snapshot에 적재합니다. 비활성 route는 제거하지만 활성 route는 모든 Tool Service가 비활성인 경우에도 빈 bundle 목록으로 유지합니다. 활성 서비스의 endpoint 정보가 잘못된 경우에는 기존 정상 bundle 목록을 유지해 일시적인 Portal 데이터 오류가 route와 Tool snapshot을 지우지 못하게 합니다. + * + * @param route Portal 응답의 route 객체입니다. + * @param updated 새 endpoint snapshot입니다. + * @param updatedRevisions 새 route revision snapshot입니다. + */ + private void registerPortalRoute( + JsonNode route, + Map> updated, + Map updatedRevisions) { + String routeKey = normalizeRouteKey(required(route, "routeKey")); + if (!isEnabled(route, "route", routeKey, true)) { + return; + } + BundleResolution resolution = toBundles(routeKey, route.get("toolServices")); + List bundles = resolution.bundles(); + if (resolution.invalidEnabledService() && bundlesByRoute.containsKey(routeKey)) { + bundles = bundlesByRoute.get(routeKey); + log.warn( + "Portal route contains invalid active Tool Service metadata; keeping last-good endpoint snapshot. routeKey={}", + routeKey); + } + updated.put(routeKey, List.copyOf(bundles)); + updatedRevisions.put(routeKey, route.path("routeRevision").asText("")); + } + + /** + * 이전·신규 route별 endpoint와 revision을 비교해 Tool manifest를 즉시 다시 읽어야 하는 route를 계산합니다. 신규·삭제 route, bundle 주소 변경, routeRevision 변경을 모두 변경으로 취급합니다. + * + * @param previousBundles 이전 endpoint snapshot입니다. + * @param previousRevisions 이전 route revision snapshot입니다. + * @param updatedBundles 신규 endpoint snapshot입니다. + * @param updatedRevisions 신규 route revision snapshot입니다. + * @return 변경된 route key 집합입니다. + */ + private Set changedRouteKeys( + Map> previousBundles, + Map previousRevisions, + Map> updatedBundles, + Map updatedRevisions) { + Set routeKeys = new HashSet<>(previousBundles.keySet()); + routeKeys.addAll(updatedBundles.keySet()); + Set changed = new HashSet<>(); + for (String routeKey : routeKeys) { + if (!java.util.Objects.equals(previousBundles.get(routeKey), updatedBundles.get(routeKey)) + || !java.util.Objects.equals( + previousRevisions.get(routeKey), updatedRevisions.get(routeKey))) { + changed.add(routeKey); + } + } + return Set.copyOf(changed); } /** @@ -286,26 +529,21 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 전체 registry 응답을 최초 수신하거나 {@code registryRevision}이 바뀐 경우에만 INFO 로그로 남깁니다. 로컬 검증용 로그이므로 endpoint와 Tool Server 설정을 포함한 응답 JSON 전체를 그대로 보여 줍니다. + * Portal routeRevision 또는 endpoint 구성이 바뀐 경우에만 변경 route와 응답 전문을 DEBUG 로그로 남깁니다. 동일 snapshot을 TTL마다 반복 출력하지 않으며 운영 로그 레벨에서는 전문이 노출되지 않습니다. * - * @param registryUrl 처리할 값입니다. - * @param registry 처리할 값입니다. - * @return 조건 충족 여부를 반환합니다. + * @param registryUrl Portal registry 조회 위치입니다. + * @param registry 변경이 확인된 Portal 응답입니다. + * @param changedRoutes endpoint 또는 revision이 변경된 route key입니다. */ - private boolean logPortalRegistryIfChanged(String registryUrl, JsonNode registry) { - String revision = registry.path("registryRevision").asText(""); - String previous = lastPortalRevision.get(); - boolean changed = previous == null || !previous.equals(revision); - if (changed && lastPortalRevision.compareAndSet(previous, revision)) { + private void logPortalRegistryIfChanged( + String registryUrl, JsonNode registry, Set changedRoutes) { + if (!changedRoutes.isEmpty()) { log.debug( - "Portal registry response accepted. registryUrl={} previousRevision={} registryRevision={} body={}", + "Portal registry response accepted. registryUrl={} changedRoutes={} body={}", registryUrl, - previous, - revision, + changedRoutes, registry.toPrettyString()); - return true; } - return false; } /** @@ -316,6 +554,9 @@ public class PortalToolRegistryClient implements ToolRegistryClient { * @return 조회 또는 변환된 목록 정보를 반환합니다. */ private List fetchRouteTools(String routeKey, List bundles) { + if (bundles.isEmpty()) { + return List.of(); + } List results = discovery.discoverAll(bundles); return merge(results); } @@ -365,6 +606,21 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } } + /** + * route별 routing hint last-good 값과 원천 bundle 구성, 마지막 조회 시각을 함께 보관하는 in-memory snapshot입니다. 직접 HTTP 요청을 처리하지 않으며 {@link #fetchRoutingManifests(String, String)}가 TTL 판정과 응답 구성을 위해 사용합니다. + * + * @param bundles snapshot을 만들 때 사용한 Portal Tool Server 목록입니다. + * @param manifestPath snapshot을 만들 때 사용한 routing hint API 경로입니다. + * @param manifestsByBundle bundleId별 마지막 성공 routing manifest입니다. + * @param lastAttemptMillis 마지막 원격 갱신 시도 시각입니다. + */ + private record RoutingHintSnapshot( + List bundles, + String manifestPath, + Map manifestsByBundle, + long lastAttemptMillis) { + } + /** * Tool Server의 routing manifest 응답을 설정된 최대 byte 안에서 문자열로 읽습니다. Agent 최적화용 설명 API라도 외부 응답이므로 기존 manifest 크기 상한을 적용해 과도한 응답이 initialize 처리 메모리를 점유하지 않게 합니다. * @@ -411,7 +667,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { * 최초 기동 또는 cache가 비어 있는 요청 시점에 포털 registry를 조회합니다. 이후 manifest 주기 refresh는 저장된 endpoint 목록만 사용하므로 포털 API와 Tool Server manifest 호출 주기를 분리합니다. */ private void ensurePortalRegistryLoaded() { - if (bundlesByRoute.isEmpty()) { + if (!portalRegistryLoaded) { refreshSourceRegistry(); } } @@ -441,25 +697,70 @@ 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 서비스가 하나도 없을 때만 갱신을 거부합니다. + * 포털의 활성 Tool Service 목록을 기존 ToolBundleDiscovery가 이해하는 bundle 선언으로 변환합니다. 모든 서비스가 명시적으로 비활성이면 정상적인 빈 목록을 반환하고, 활성 서비스의 필수 endpoint 정보가 누락되면 기존 snapshot 보존 여부를 결정할 수 있도록 오류 여부를 함께 반환합니다. * * @param routeKey 처리 대상 route key입니다. - * @param services 입력값입니다. - * @return 조회된 선택값을 반환합니다. + * @param services Portal route의 Tool Service 배열입니다. + * @return 변환된 bundle 목록과 활성 서비스 메타데이터 오류 여부입니다. */ - private Optional> toBundles(String routeKey, JsonNode services) { + private BundleResolution toBundles(String routeKey, JsonNode services) { List bundles = new ArrayList<>(); + boolean invalidEnabledService = services == null || !services.isArray(); + if (invalidEnabledService) { + log.warn("Portal route Tool Service list is invalid. routeKey={}", routeKey); + return new BundleResolution(List.of(), true); + } for (JsonNode service : services) { - if (!"ACTIVE".equalsIgnoreCase(service.path("status").asText("ACTIVE"))) { + String serviceKey = service.path("serviceKey").asText(""); + if (!isEnabled(service, "Tool Service", serviceKey, false)) { continue; } - toBundle(routeKey, service).ifPresent(bundles::add); + Optional bundle = toBundle(routeKey, service); + if (bundle.isPresent()) { + bundles.add(bundle.get()); + } else { + invalidEnabledService = true; + } } - if (bundles.isEmpty()) { - log.warn("Portal route ignored because it has no usable active Tool Service. routeKey={}", routeKey); - return Optional.empty(); + return new BundleResolution(List.copyOf(bundles), invalidEnabledService); + } + + /** + * 활성 route의 Tool Service endpoint 변환 결과입니다. 직접 요청을 처리하지 않으며 Portal snapshot 교체 시 정상적인 빈 목록과 잘못된 활성 서비스 정보를 구분하는 데 사용합니다. + * + * @param bundles 정상적으로 변환된 활성 Tool Service bundle 목록입니다. + * @param invalidEnabledService 활성 서비스 중 필수 endpoint 정보가 잘못된 항목이 있으면 {@code true}입니다. + */ + private record BundleResolution(List bundles, boolean invalidEnabledService) { + } + + /** + * Portal의 {@code enabled} 값을 {@code Y}/{@code N}으로 엄격하게 해석합니다. 전환 기간 동안 필드가 없으면 route는 활성으로, Tool Service는 기존 {@code status=ACTIVE} 규칙으로 해석하며 잘못된 값이나 타입은 전체 Portal 응답 오류로 처리합니다. + * + * @param node 검사할 route 또는 Tool Service 객체입니다. + * @param entryType 오류 메시지에 사용할 항목 유형입니다. + * @param entryKey 오류 메시지에 사용할 식별자입니다. + * @param defaultEnabled enabled가 없는 route의 호환 기본값입니다. + * @return 활성 항목이면 {@code true}, 비활성 항목이면 {@code false}입니다. + */ + private boolean isEnabled( + JsonNode node, String entryType, String entryKey, boolean defaultEnabled) { + JsonNode enabled = node.get("enabled"); + if (enabled == null || enabled.isNull()) { + if (node.has("status")) { + return "ACTIVE".equalsIgnoreCase(node.path("status").asText("")); + } + return defaultEnabled; } - return Optional.of(List.copyOf(bundles)); + if (!enabled.isTextual()) { + throw unavailable("Portal " + entryType + " enabled must be Y or N: " + entryKey); + } + return switch (enabled.asText().trim().toUpperCase(java.util.Locale.ROOT)) { + case "Y" -> true; + case "N" -> false; + default -> throw unavailable( + "Portal " + entryType + " enabled must be Y or N: " + entryKey); + }; } /** 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 5230b9b..f2c0d86 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 @@ -3,6 +3,7 @@ package io.shinhanlife.dat.biz.mcp.registry; import com.fasterxml.jackson.databind.JsonNode; import java.util.List; import java.util.Map; +import java.util.Set; /** * @package io.shinhanlife.dat.biz.mcp.registry @@ -16,6 +17,9 @@ import java.util.Map; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.16 j.h.w 기동 시 Agent routing hint preload 확장점 추가 + * 2026.09.17 j.h.w Portal routeRevision 변경 route 조회 확장점 추가 + * 2026.09.17 j.h.w Portal에서 제거된 route 조회 확장점 추가 * * */ @@ -47,6 +51,24 @@ public interface ToolRegistryClient { return false; } + /** + * 가장 최근 Portal registry 갱신에서 endpoint 구성 또는 route revision이 변경된 route key를 반환합니다. Portal을 사용하지 않는 원천은 빈 집합을 반환하며, 호출자는 이 값을 이용해 변경된 route의 Tool manifest만 즉시 갱신합니다. + * + * @return 가장 최근 원천 갱신에서 변경된 route key의 불변 집합입니다. + */ + default Set changedSourceRouteKeys() { + return Set.of(); + } + + /** + * 가장 최근 원천 갱신에서 비활성화되거나 삭제된 route key를 반환합니다. Portal을 사용하지 않는 원천은 빈 집합을 반환하며, 호출자는 이 값을 이용해 route별 Tool memory snapshot과 TTL 상태를 정리합니다. + * + * @return 가장 최근 원천 갱신에서 제거된 route key의 불변 집합입니다. + */ + default Set removedSourceRouteKeys() { + return Set.of(); + } + /** * 요청 경로에서 받은 route key가 현재 Registry 원천이 알고 있는 route인지 확인합니다. 기본 구현은 route 개념이 없는 정적 Registry 구현과의 호환을 위해 허용으로 처리하며, Portal 기반 구현은 메모리에 적재된 endpoint snapshot만 조회해야 합니다. * @@ -68,6 +90,12 @@ public interface ToolRegistryClient { return List.of(); } + /** + * 서버 기동 시 현재 원천이 알고 있는 모든 route의 Agent routing hint snapshot을 best-effort로 미리 적재합니다. routing hint를 지원하지 않는 구현은 아무 작업도 하지 않습니다. + */ + default void preloadRoutingHints() { + } + /** * route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다. * diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryPreloader.java similarity index 71% rename from src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java rename to src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryPreloader.java index 775b293..a0d108f 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryPreloader.java @@ -8,8 +8,8 @@ import org.springframework.stereotype.Component; /** * @package io.shinhanlife.dat.biz.mcp.registry - * @className ToolRegistryRefreshScheduler - * @description 시작 시점에만 Tool Registry cache를 선행 갱신하는 preload 컴포넌트입니다. + * @className ToolRegistryPreloader + * @description HTTP/MCP 요청을 직접 처리하지 않고, 애플리케이션 준비 이벤트를 받으면 ToolRegistryService를 통해 Portal registry, Tool manifest, routing hint snapshot을 최초 한 번 적재하는 컴포넌트입니다. * @author j.h.w * @create 2026.08.06 * @@ -18,13 +18,14 @@ import org.springframework.stereotype.Component; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.16 j.h.w 기동 시 Agent routing hint snapshot preload 추가 * * */ @Component -public class ToolRegistryRefreshScheduler { +public class ToolRegistryPreloader { - private static final Logger logger = LoggerFactory.getLogger(ToolRegistryRefreshScheduler.class); + private static final Logger logger = LoggerFactory.getLogger(ToolRegistryPreloader.class); private final ToolRegistryService registryService; private volatile boolean firstAttemptCompleted; @@ -33,12 +34,12 @@ public class ToolRegistryRefreshScheduler { * * @param registryService 협력 객체입니다. */ - public ToolRegistryRefreshScheduler(ToolRegistryService registryService) { + public ToolRegistryPreloader(ToolRegistryService registryService) { this.registryService = registryService; } /** - * 애플리케이션 준비 직후 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 상태로 교체합니다. 이후 갱신은 scheduler가 아니라 요청 시점 TTL 확인에서 수행합니다. 각 단계가 실패해도 애플리케이션은 계속 기동합니다. + * 애플리케이션 준비 직후 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 상태로 교체합니다. 이후 갱신은 주기 실행이 아니라 요청 시점 TTL 확인에서 수행합니다. 각 단계가 실패해도 애플리케이션은 계속 기동합니다. * * @return 처리 결과를 반환합니다. */ @@ -47,6 +48,7 @@ public class ToolRegistryRefreshScheduler { safeWarmStart(); safePortalRefresh("preload"); safeManifestRefresh("preload"); + safeRoutingHintRefresh("preload"); firstAttemptCompleted = true; } @@ -89,6 +91,23 @@ public class ToolRegistryRefreshScheduler { } } + /** + * Agent routing hint preload 실패를 기본 Tool catalog 기동 흐름과 격리합니다. 실패해도 서버와 initialize 기본 응답은 유지되며, 이후 요청 시점 TTL 갱신에서 다시 시도할 수 있습니다. + * + * @param trigger preload 실행 원인입니다. + */ + private void safeRoutingHintRefresh(String trigger) { + try { + registryService.preloadRoutingHints(); + } catch (RuntimeException exception) { + logger.warn( + "Tool routing hint preload failed: trigger={}, reason={}, message={}", + trigger, + exception.getClass().getSimpleName(), + exception.getMessage()); + } + } + /** * 포털 registry endpoint 목록 갱신 실패를 로그로 격리하여 manifest refresh와 요청 경로에 영향을 주지 않게 합니다. * 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 7a84764..faa77f6 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 @@ -11,10 +11,13 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.LongSupplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,7 +28,7 @@ import org.springframework.stereotype.Service; /** * @package io.shinhanlife.dat.biz.mcp.registry * @className ToolRegistryService - * @description Tool Registry metadata 조회를 담당하는 서비스입니다. + * @description tools/list와 tools/call 요청에서 route별 in-memory Tool snapshot을 조회하고, Portal registry와 Tool manifest의 요청 시점 TTL 갱신·single-flight·last-good 보존을 관리하는 서비스입니다. * @author j.h.w * @create 2026.08.06 * @@ -34,6 +37,10 @@ import org.springframework.stereotype.Service; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.16 j.h.w 기동 시 Agent routing hint snapshot preload 연결 + * 2026.09.17 j.h.w routeRevision 변경 route별 manifest 즉시 갱신 추가 + * 2026.09.17 j.h.w 비활성·삭제 route의 memory snapshot 및 TTL 상태 정리 + * 2026.09.17 j.h.w Portal single-flight와 Tool miss refresh cooldown 추가 * * */ @@ -41,6 +48,7 @@ import org.springframework.stereotype.Service; public class ToolRegistryService implements McpRouteKeyValidator { private static final Logger log = LoggerFactory.getLogger(ToolRegistryService.class); + private static final long TOOL_MISS_REFRESH_COOLDOWN_MILLIS = 5_000L; private final ToolRegistryClient registryClient; private final Optional redisCache; @@ -52,6 +60,9 @@ public class ToolRegistryService implements McpRouteKeyValidator { private final ConcurrentMap>> refreshInFlightByRoute = new ConcurrentHashMap<>(); private final ConcurrentMap manifestRefreshAttemptsByRoute = new ConcurrentHashMap<>(); + private final ConcurrentMap portalChangesByRoute = new ConcurrentHashMap<>(); + private final Set removedRoutes = ConcurrentHashMap.newKeySet(); + private final AtomicReference> portalRefreshInFlight = new AtomicReference<>(); private volatile long portalRefreshAttemptMillis = Long.MIN_VALUE; private volatile long portalChangeMillis = Long.MIN_VALUE; @@ -198,7 +209,9 @@ public class ToolRegistryService implements McpRouteKeyValidator { return match.get(); } - // A cache may be stale. Perform one direct lookup before declaring the tool missing. + if (!claimToolMissRefreshSlot(normalizedRouteKey)) { + throw notFound(name); + } try { List refreshed = refresh(normalizedRouteKey); return match(refreshed, name).orElseThrow(() -> notFound(name)); @@ -266,15 +279,33 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다. Tool manifest 조회와 memory snapshot 교체는 여기서 직접 수행하지 않고, 변경 여부와 변경 시각을 남겨 같은 요청의 route manifest 갱신 판단에 사용합니다. + * 기동 preload 단계에서 Registry client가 제공하는 route별 Agent routing hint snapshot을 미리 적재합니다. 지원하지 않는 원천은 기본 no-op으로 처리하고 실제 실패 격리는 호출자인 preload 컴포넌트가 담당합니다. + */ + public void preloadRoutingHints() { + registryClient.preloadRoutingHints(); + } + + /** + * Portal endpoint와 routeRevision snapshot을 갱신하고 변경된 route별 시각을 기록합니다. 비활성화되거나 삭제된 route는 Tool memory snapshot과 TTL 상태를 즉시 제거하며, Tool manifest는 남은 route의 요청 시점 TTL 판단에 따라 갱신합니다. Redis는 현재 정책상 정리 대상에 포함하지 않습니다. * - * @return 조건 충족 여부를 반환합니다. + * @return Portal 원천에서 변경 route가 하나라도 확인되면 {@code true}입니다. */ public boolean refreshSourceRegistry() { try { boolean changed = registryClient.refreshSourceRegistry(); if (changed) { - portalChangeMillis = currentTimeMillis.getAsLong(); + long changedAt = currentTimeMillis.getAsLong(); + Set changedRoutes = registryClient.changedSourceRouteKeys(); + Set removedSourceRoutes = registryClient.removedSourceRouteKeys(); + changedRoutes.stream() + .filter(routeKey -> !removedSourceRoutes.contains(routeKey)) + .forEach(removedRoutes::remove); + if (changedRoutes.isEmpty()) { + portalChangeMillis = changedAt; + } else { + changedRoutes.forEach(routeKey -> portalChangesByRoute.put(routeKey, changedAt)); + } + removedSourceRoutes.forEach(this::removeRouteState); } return changed; } finally { @@ -286,7 +317,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { * 요청 시점에 Portal registry TTL이 만료되었으면 endpoint 목록을 best-effort로 갱신합니다. 실패해도 기존 in-memory endpoint snapshot은 Registry client의 fallback 정책에 맡기며, 같은 TTL 구간에서 요청마다 Portal을 반복 호출하지 않도록 마지막 시도 시각을 남깁니다. */ @Override - public synchronized void refreshSourceRegistryIfStale() { + public void refreshSourceRegistryIfStale() { boolean portalDue = isPortalRefreshDue(); log.debug( "TEMP_PORTAL_TTL_REFRESH_DECISION phase=route_validation portalDue={} action={}", @@ -295,7 +326,55 @@ public class ToolRegistryService implements McpRouteKeyValidator { if (!portalDue) { return; } - refreshSourceRegistry(); + refreshSourceRegistrySingleFlightIfDue(); + } + + /** + * Portal TTL이 만료된 경우 하나의 요청만 원천 갱신을 수행하고, 동시에 진입한 요청은 같은 결과를 기다립니다. monitor를 잡은 채 HTTP 통신하지 않으므로 virtual thread의 carrier pinning을 피하고, 완료된 갱신의 성공·실패를 모든 대기 요청에 동일하게 전달합니다. + * + * @return Portal 원천 변경 여부이며 TTL 재확인 결과 갱신이 불필요하면 {@code false}입니다. + */ + private boolean refreshSourceRegistrySingleFlightIfDue() { + if (!isPortalRefreshDue()) { + return false; + } + while (true) { + CompletableFuture running = portalRefreshInFlight.get(); + if (running != null) { + return awaitPortalRefresh(running); + } + CompletableFuture candidate = new CompletableFuture<>(); + if (!portalRefreshInFlight.compareAndSet(null, candidate)) { + continue; + } + try { + boolean changed = isPortalRefreshDue() && refreshSourceRegistry(); + candidate.complete(changed); + return changed; + } catch (RuntimeException exception) { + candidate.completeExceptionally(exception); + throw exception; + } finally { + portalRefreshInFlight.compareAndSet(candidate, null); + } + } + } + + /** + * 이미 수행 중인 Portal refresh를 기다리고, 실패한 경우 원래 RuntimeException 유형을 보존해 호출자에게 전달합니다. CompletableFuture 대기를 사용하므로 synchronized monitor를 점유하지 않습니다. + * + * @param refresh 공유 중인 Portal refresh 결과입니다. + * @return Portal 원천 변경 여부를 반환합니다. + */ + private boolean awaitPortalRefresh(CompletableFuture refresh) { + try { + return refresh.join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw exception; + } } /** @@ -306,10 +385,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { public void refreshIfStale(String routeKey) { String normalizedRouteKey = normalizeRouteKey(routeKey); boolean portalDue = isPortalRefreshDue(); - boolean portalChanged = false; - if (portalDue) { - portalChanged = refreshSourceRegistry(); - } + boolean portalChanged = portalDue && refreshSourceRegistrySingleFlightIfDue(); boolean portalChangeNewerThanManifest = isPortalChangeNewerThanManifest(normalizedRouteKey); boolean manifestDue = isManifestRefreshDue(normalizedRouteKey); boolean manifestRefresh = portalChanged || portalChangeNewerThanManifest || manifestDue; @@ -349,6 +425,11 @@ public class ToolRegistryService implements McpRouteKeyValidator { try { List tools = registryClient.fetchTools(routeKey).stream().filter(ToolMetadata::enabled).toList(); + if (removedRoutes.contains(routeKey)) { + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, + "Portal registry route was removed: " + routeKey); + } List immutableTools = List.copyOf(tools); List previous = snapshotsByRoute.put(routeKey, immutableTools); logSnapshot(routeKey, previous, immutableTools); @@ -356,6 +437,9 @@ public class ToolRegistryService implements McpRouteKeyValidator { redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, tools)); return tools; } catch (RuntimeException exception) { + if (removedRoutes.contains(routeKey)) { + throw exception; + } List memory = snapshotsByRoute.get(routeKey); if (memory != null) { return memory; @@ -385,6 +469,18 @@ public class ToolRegistryService implements McpRouteKeyValidator { redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools)); } + /** + * Portal에서 비활성화되거나 삭제된 route의 Tool snapshot과 TTL 변경 상태를 memory에서 제거합니다. 진행 중이던 manifest 조회가 늦게 완료되어 snapshot을 복원하지 못하도록 제거 표식을 먼저 기록하며 Redis에는 접근하지 않습니다. + * + * @param routeKey 정리할 route key입니다. + */ + private void removeRouteState(String routeKey) { + removedRoutes.add(routeKey); + snapshotsByRoute.remove(routeKey); + manifestRefreshAttemptsByRoute.remove(routeKey); + portalChangesByRoute.remove(routeKey); + } + /** * 이미 시작된 같은 route의 refresh 결과를 기다리고 원래 RuntimeException 유형을 보존해 전달합니다. 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 source fetch 결과를 재사용하게 합니다. * @@ -500,6 +596,25 @@ public class ToolRegistryService implements McpRouteKeyValidator { return isExpired(portalRefreshAttemptMillis, properties.portal().refreshTtlSeconds()); } + /** + * Tool 이름 miss로 인한 강제 manifest refresh를 route별 cooldown 안에서 한 번만 허용합니다. 기존 manifest 시도 시각 Map을 원자적으로 갱신해 동시 miss는 한 요청만 refresh하게 하고, 반복되는 폐기 Tool 호출이 Tool Server 조회량으로 증폭되지 않게 합니다. + * + * @param routeKey Tool을 찾지 못한 route key입니다. + * @return 이번 요청이 강제 refresh를 수행할 수 있으면 {@code true}입니다. + */ + private boolean claimToolMissRefreshSlot(String routeKey) { + long now = currentTimeMillis.getAsLong(); + AtomicBoolean claimed = new AtomicBoolean(false); + manifestRefreshAttemptsByRoute.compute(routeKey, (key, previous) -> { + if (previous == null || now - previous >= TOOL_MISS_REFRESH_COOLDOWN_MILLIS) { + claimed.set(true); + return now; + } + return previous; + }); + return claimed.get(); + } + /** * 지정 route의 Tool manifest 조회가 필요한 시점인지 판단합니다. 아직 snapshot이 없으면 TTL과 무관하게 조회가 필요하며, snapshot이 있으면 마지막 manifest 조회 시도 이후 TTL이 지났을 때만 true를 반환합니다. * @@ -515,13 +630,13 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * Portal endpoint 목록 변경이 해당 route의 마지막 manifest 조회보다 나중에 발생했는지 확인합니다. 필터 단계에서 Portal TTL refresh가 먼저 수행된 요청도 controller 단계에서 manifest 갱신을 놓치지 않게 합니다. + * Portal endpoint 또는 routeRevision 변경이 해당 route의 마지막 manifest 조회보다 나중에 발생했는지 확인합니다. 필터 단계에서 Portal TTL refresh가 먼저 수행된 요청도 controller 단계에서 해당 route의 manifest 갱신을 놓치지 않게 합니다. * * @param routeKey 처리 대상 route key입니다. * @return Portal 변경 이후 manifest 재조회가 필요한지 여부를 반환합니다. */ private boolean isPortalChangeNewerThanManifest(String routeKey) { - long changedAt = portalChangeMillis; + long changedAt = portalChangesByRoute.getOrDefault(routeKey, portalChangeMillis); Long manifestAttemptMillis = manifestRefreshAttemptsByRoute.get(routeKey); return changedAt != Long.MIN_VALUE && (manifestAttemptMillis == null || changedAt > manifestAttemptMillis); 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 edbbaf9..47b3e16 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 @@ -17,8 +17,10 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.UUID; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatusCode; @@ -42,6 +44,7 @@ import org.springframework.web.client.RestClientException; * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 * 2026.09.08 j.h.w 임시 local fixture 활성 시 실제 HTTP 호출 비활성화 + * 2026.09.17 j.h.w Tool 호출자 IP와 host name을 기동 시 한 번만 확정 * * */ @@ -58,21 +61,42 @@ public class HttpToolClient implements ToolClient { private final ObjectMapper objectMapper; private final McpProperties properties; private final HttpClient toolHttpClient; + private final String callerIp; + private final String callerHost; /** - * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받습니다. 생성 시점에는 외부 호출을 하지 않고, {@link #execute(ToolRequest, McpRequestContext)}에서 요청별 timeout과 표준 헤더를 조합합니다. + * JSON 변환, Tool 설정, 공유 connection pool을 가진 HTTP client를 주입받고 Tool Service에 전달할 호출자 IP와 host name을 한 번 확정합니다. OpenShift 환경에서는 Downward API 환경변수를 우선 사용하며, 로컬 실행에서는 OS host 조회 결과를 사용합니다. * * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. * @param properties MCP 설정 정보입니다. * @param toolHttpClient Tool 처리 정보입니다. */ + @Autowired public HttpToolClient( ObjectMapper objectMapper, McpProperties properties, @Qualifier("toolHttpClient") HttpClient toolHttpClient) { + this(objectMapper, properties, toolHttpClient, resolveCallerIdentity()); + } + + /** + * 테스트 또는 명시적 구성에서 이미 확정한 호출자 정보를 사용해 client를 생성합니다. Tool 호출마다 host 정보를 다시 조회하지 않도록 생성 시점의 값을 인스턴스 수명 동안 재사용합니다. + * + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. + * @param toolHttpClient Tool Service 호출용 공유 HTTP client입니다. + * @param callerIdentity Tool Service 헤더에 전달할 MCP 서버 식별 정보입니다. + */ + HttpToolClient( + ObjectMapper objectMapper, + McpProperties properties, + HttpClient toolHttpClient, + CallerIdentity callerIdentity) { this.objectMapper = objectMapper; this.properties = properties; this.toolHttpClient = toolHttpClient; + this.callerIp = callerIdentity.ip(); + this.callerHost = callerIdentity.host(); } /** @@ -160,8 +184,8 @@ public class HttpToolClient implements ToolClient { set(headers, McpRequestContextFactory.HEADER_APP_CODE, context.appCode()); set(headers, McpRequestContextFactory.HEADER_PROJECT_CODE, context.projectCode()); set(headers, McpRequestContextFactory.HEADER_USER_IP, context.userIp()); - set(headers, McpRequestContextFactory.HEADER_CALLER_IP, localHostAddress()); - set(headers, McpRequestContextFactory.HEADER_CALLER_HOST, localHostName()); + set(headers, McpRequestContextFactory.HEADER_CALLER_IP, callerIp); + set(headers, McpRequestContextFactory.HEADER_CALLER_HOST, callerHost); set(headers, McpRequestContextFactory.HEADER_CHANNEL, "MCP"); set(headers, McpRequestContextFactory.HEADER_AGENT_ID, context.agentId()); set(headers, "mcp-session-id", context.mcpSessionId()); @@ -218,29 +242,78 @@ public class HttpToolClient implements ToolClient { } /** - * 현재 MCP 서버의 IP를 Tool Service 호출자 IP 헤더에 넣기 위해 조회합니다. OS 조회가 실패하면 빈 값을 보내지 않고 보수적으로 {@code unknown}을 사용해 문제 위치가 드러나게 합니다. + * 컨테이너 환경변수와 로컬 host fallback을 이용해 Tool Service에 전달할 MCP 서버 식별 정보를 확정합니다. Pod 정보가 모두 제공되면 DNS 조회를 생략하고, 누락된 값이 있을 때만 fallback을 한 번 호출합니다. * - * @return 처리된 문자열 값을 반환합니다. + * @return 프로세스 수명 동안 재사용할 호출자 IP와 host name을 반환합니다. */ - private String localHostAddress() { + private static CallerIdentity resolveCallerIdentity() { + return resolveCallerIdentity( + System.getenv("POD_IP"), System.getenv("POD_NAME"), HttpToolClient::localHost); + } + + /** + * 주어진 Pod 정보에서 비어 있는 항목만 로컬 host 조회 결과로 보완합니다. fallback 조회가 실패하면 WARN을 한 번 남기고 누락된 값을 {@code unknown}으로 고정해 Tool 요청 경로에서 DNS를 반복하지 않습니다. + * + * @param podIp Downward API가 제공한 Pod IP입니다. + * @param podName Downward API가 제공한 Pod 이름입니다. + * @param localHostSupplier 로컬 개발 또는 Pod 환경변수 누락 시 사용할 host 조회 함수입니다. + * @return 확정된 호출자 IP와 host name입니다. + */ + static CallerIdentity resolveCallerIdentity( + String podIp, String podName, Supplier localHostSupplier) { + String resolvedIp = nonBlank(podIp); + String resolvedHost = nonBlank(podName); + if (resolvedIp != null && resolvedHost != null) { + return new CallerIdentity(resolvedIp, resolvedHost); + } try { - return InetAddress.getLocalHost().getHostAddress(); + InetAddress localHost = localHostSupplier.get(); + if (resolvedIp == null) { + resolvedIp = nonBlank(localHost.getHostAddress()); + } + if (resolvedHost == null) { + resolvedHost = nonBlank(localHost.getHostName()); + } + } catch (RuntimeException exception) { + log.warn( + "MCP caller host resolution failed. Missing values will use unknown. reason={}", + exception.getClass().getSimpleName()); + } + return new CallerIdentity( + resolvedIp == null ? "unknown" : resolvedIp, + resolvedHost == null ? "unknown" : resolvedHost); + } + + /** + * JDK host 조회의 checked exception을 호출자 정보 초기화 경계에서 RuntimeException으로 변환합니다. 실제 실패는 상위 resolver가 WARN과 {@code unknown} fallback으로 처리합니다. + * + * @return 현재 프로세스가 실행 중인 host 주소 정보입니다. + */ + private static InetAddress localHost() { + try { + return InetAddress.getLocalHost(); } catch (Exception exception) { - return "unknown"; + throw new IllegalStateException("Local host is unavailable", exception); } } /** - * 현재 MCP 서버의 host name을 Tool Service 호출자 host 헤더에 넣기 위해 조회합니다. 컨테이너나 폐쇄망 설정 문제로 조회가 실패하면 {@code unknown}을 사용합니다. + * 공백 문자열을 미설정 값으로 정규화해 Pod 환경변수와 로컬 조회 결과에 같은 fallback 규칙을 적용합니다. * - * @return 처리된 문자열 값을 반환합니다. + * @param value 확인할 환경 또는 host 값입니다. + * @return 공백이 아닌 값 또는 {@code null}입니다. */ - private String localHostName() { - try { - return InetAddress.getLocalHost().getHostName(); - } catch (Exception exception) { - return "unknown"; - } + private static String nonBlank(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + /** + * Tool Service의 {@code X-Caller-IP}, {@code X-Caller-Host} 헤더에 사용할 MCP 서버 식별 정보입니다. 요청마다 재계산하지 않고 HttpToolClient 인스턴스가 보관합니다. + * + * @param ip MCP 서버 또는 Pod IP입니다. + * @param host MCP 서버 또는 Pod 이름입니다. + */ + record CallerIdentity(String ip, String host) { } /** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 20a87f2..67c5267 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -101,6 +101,7 @@ mcp: agent-routing-hints: enabled: ${MCP_AGENT_ROUTING_HINTS_ENABLED:true} manifest-path: ${MCP_AGENT_ROUTING_HINTS_MANIFEST_PATH:/tool-service-manifest} + refresh-ttl-seconds: ${MCP_AGENT_ROUTING_HINTS_REFRESH_TTL_SECONDS:300} # Temporary local/dev fixtures. Keep disabled by default and remove after real Tool Servers are available. local-fixtures: enabled: ${MCP_LOCAL_FIXTURES_ENABLED:false} diff --git a/src/main/resources/config/local-toolserver-info-sample-v1.json b/src/main/resources/config/local-toolserver-info-sample-v1.json index 88fcd7c..02e31c5 100644 --- a/src/main/resources/config/local-toolserver-info-sample-v1.json +++ b/src/main/resources/config/local-toolserver-info-sample-v1.json @@ -1,52 +1,59 @@ { - "registryRevision": "local-toolserver-info-sample-v1", "routes": [ { "routeKey": "cus", + "enabled": "Y", + "routeRevision": "2026-09-17T00:00:00+09:00", "toolServices": [ { "serviceKey": "was-cus", "displayName": "CUS Tool Server", "serviceDomain": "https://tool-cus.devjun.net", "manifestPath": "/tool-manifest", - "status": "ACTIVE" + "enabled": "N" } ] }, { "routeKey": "sal", + "enabled": "Y", + "routeRevision": "2026-09-17T00:00:00+09:00", "toolServices": [ { "serviceKey": "was-sal", "displayName": "SAL Tool Server", "serviceDomain": "https://tool-sal.devjun.net", "manifestPath": "/tool-manifest", - "status": "ACTIVE" + "enabled": "Y" } ] }, { "routeKey": "pro", + "enabled": "Y", + "routeRevision": "2026-09-17T00:00:00+09:00", "toolServices": [ { "serviceKey": "was-pro", "displayName": "PRO Tool Server", "serviceDomain": "https://tool-pro.devjun.net", "manifestPath": "/tool-manifest", - "status": "ACTIVE" + "enabled": "Y" } ] }, { "routeKey": "sys", + "enabled": "Y", + "routeRevision": "2026-09-17T00:00:00+09:00", "toolServices": [ { "serviceKey": "was-sys", "displayName": "SYS Tool Server", "serviceDomain": "https://tool-sys.devjun.net", "manifestPath": "/tool-manifest", - "status": "ACTIVE" + "enabled": "Y" } ] } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index ca10973..aba40b8 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -9,7 +9,7 @@ UTF-8 - + diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java index 852c43f..1ec60f3 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/method/InitializeHandlerTest.java @@ -102,7 +102,7 @@ class InitializeHandlerTest { .thenReturn(List.of(routingManifest)); InitializeHandler handler = new InitializeHandler( properties(false, false), - new AgentRoutingHintsProperties(true, "tool-service-manifest"), + new AgentRoutingHintsProperties(true, "tool-service-manifest", 300), registryClient, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER); JsonRpcRequest request = new JsonRpcRequest( @@ -129,7 +129,7 @@ class InitializeHandlerTest { .thenThrow(new IllegalStateException("routing manifest unavailable")); InitializeHandler handler = new InitializeHandler( properties(false, false), - new AgentRoutingHintsProperties(true, "/tool-service-manifest"), + new AgentRoutingHintsProperties(true, "/tool-service-manifest", 300), registryClient, io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER); JsonRpcRequest request = new JsonRpcRequest( diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java index 2fbfd2e..65da172 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/observability/ToolCatalogHealthIndicatorTest.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryRefreshScheduler; +import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryPreloader; import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryService; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.health.Status; @@ -13,37 +13,37 @@ class ToolCatalogHealthIndicatorTest { @Test void staysDownUntilTheFirstDiscoveryAttemptFinishes() { - ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class); + ToolRegistryPreloader preloader = mock(ToolRegistryPreloader.class); ToolRegistryService registryService = mock(ToolRegistryService.class); when(registryService.hasUsableSnapshot()).thenReturn(true); ToolCatalogHealthIndicator indicator = - new ToolCatalogHealthIndicator(scheduler, registryService); + new ToolCatalogHealthIndicator(preloader, registryService); assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); } @Test void staysDownWhenDiscoveryFinishedWithoutAUsableSnapshot() { - ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class); + ToolRegistryPreloader preloader = mock(ToolRegistryPreloader.class); ToolRegistryService registryService = mock(ToolRegistryService.class); - when(scheduler.firstAttemptCompleted()).thenReturn(true); + when(preloader.firstAttemptCompleted()).thenReturn(true); ToolCatalogHealthIndicator indicator = - new ToolCatalogHealthIndicator(scheduler, registryService); + new ToolCatalogHealthIndicator(preloader, registryService); assertThat(indicator.health().getStatus()).isEqualTo(Status.DOWN); } @Test void becomesReadyWhenDiscoveryFinishedWithAUsableSnapshot() { - ToolRegistryRefreshScheduler scheduler = mock(ToolRegistryRefreshScheduler.class); + ToolRegistryPreloader preloader = mock(ToolRegistryPreloader.class); ToolRegistryService registryService = mock(ToolRegistryService.class); - when(scheduler.firstAttemptCompleted()).thenReturn(true); + when(preloader.firstAttemptCompleted()).thenReturn(true); when(registryService.hasUsableSnapshot()).thenReturn(true); ToolCatalogHealthIndicator indicator = - new ToolCatalogHealthIndicator(scheduler, registryService); + new ToolCatalogHealthIndicator(preloader, registryService); assertThat(indicator.health().getStatus()).isEqualTo(Status.UP); } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java index 5b10fe5..13835bb 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; +import io.shinhanlife.dat.biz.mcp.config.AgentRoutingHintsProperties; import io.shinhanlife.dat.biz.mcp.config.LocalFixtureProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties; import java.nio.charset.StandardCharsets; @@ -18,6 +19,8 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterEach; @@ -87,6 +90,201 @@ class PortalToolRegistryClientTest { verify(redis, never()).loadRegistry(); } + @Test + void excludesDisabledRouteAndToolServiceFromPortalSnapshot() { + portal.enqueue(jsonResponse( + """ + { + "routes": [ + { + "routeKey": "disabled-route", + "enabled": "N", + "routeRevision": "route-1", + "toolServices": [ { + "serviceKey": "disabled-route-server", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "enabled": "Y" + } ] + }, + { + "routeKey": "external", + "enabled": "Y", + "routeRevision": "route-1", + "toolServices": [ + { + "serviceKey": "disabled-server", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "enabled": "N" + }, + { + "serviceKey": "external-tool-server", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "enabled": "Y" + } + ] + } + ] + } + """ + .formatted( + toolServer.url("").toString().replaceAll("/+$", ""), + toolServer.url("").toString().replaceAll("/+$", ""), + toolServer.url("").toString().replaceAll("/+$", "")))); + toolServer.enqueue(manifest("manifest-1", "external.weather")); + PortalToolRegistryClient client = client(); + + Map> snapshots = client.fetchAllTools(); + + assertThat(snapshots).containsOnlyKeys("external"); + assertThat(client.isKnownRoute("disabled-route")).isFalse(); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + } + + @Test + void keepsEnabledRouteWithEmptyToolsWhenAllToolServicesAreDisabled() { + portal.enqueue(jsonResponse( + """ + { + "routes": [ { + "routeKey": "external", + "enabled": "Y", + "routeRevision": "route-1", + "toolServices": [ { + "serviceKey": "external-tool-server", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "enabled": "N" + } ] + } ] + } + """ + .formatted(toolServer.url("").toString().replaceAll("/+$", "")))); + PortalToolRegistryClient client = client(); + + Map> snapshots = client.fetchAllTools(); + + assertThat(client.isKnownRoute("external")).isTrue(); + assertThat(snapshots).containsEntry("external", List.of()); + assertThat(client.fetchTools("external")).isEmpty(); + assertThat(client.fetchRoutingManifests("external", "/tool-service-manifest")).isEmpty(); + assertThat(toolServer.getRequestCount()).isZero(); + assertThat(portal.getRequestCount()).isEqualTo(1); + } + + @Test + void keepsLastGoodEndpointSnapshotWhenEnabledToolServiceMetadataBecomesInvalid() { + portal.enqueue(jsonResponse(portalRegistryJson("route-1"))); + portal.enqueue(jsonResponse( + """ + { + "routes": [ { + "routeKey": "external", + "enabled": "Y", + "routeRevision": "route-2", + "toolServices": [ { + "serviceKey": "external-tool-server", + "manifestPath": "/tool-manifest", + "enabled": "Y" + } ] + } ] + } + """)); + toolServer.enqueue(manifest("manifest-1", "external.weather")); + PortalToolRegistryClient client = client(); + client.refreshSourceRegistry(); + + boolean changed = client.refreshSourceRegistry(); + Map> snapshots = client.fetchAllTools(); + + assertThat(changed).isTrue(); + assertThat(client.isKnownRoute("external")).isTrue(); + assertThat(snapshots.get("external")) + .extracting(ToolMetadata::name) + .containsExactly("external.weather"); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + } + + @Test + void keepsNewEnabledRouteWithEmptyToolsWhenToolServiceMetadataIsInvalid() { + portal.enqueue(jsonResponse( + """ + { + "routes": [ { + "routeKey": "external", + "enabled": "Y", + "routeRevision": "route-1", + "toolServices": [ { + "serviceKey": "external-tool-server", + "enabled": "Y" + } ] + } ] + } + """)); + PortalToolRegistryClient client = client(); + + Map> snapshots = client.fetchAllTools(); + + assertThat(client.isKnownRoute("external")).isTrue(); + assertThat(snapshots).containsEntry("external", List.of()); + assertThat(toolServer.getRequestCount()).isZero(); + } + + @Test + void detectsRouteRevisionChangeWithoutEndpointChange() { + portal.enqueue(jsonResponse(portalRegistryJson("route-1"))); + portal.enqueue(jsonResponse(portalRegistryJson("route-2"))); + PortalToolRegistryClient client = client(); + + boolean firstLoad = client.refreshSourceRegistry(); + boolean revisionChanged = client.refreshSourceRegistry(); + + assertThat(firstLoad).isTrue(); + assertThat(revisionChanged).isTrue(); + assertThat(client.changedSourceRouteKeys()).containsExactly("external"); + assertThat(toolServer.getRequestCount()).isZero(); + } + + @Test + void removesRouteWhenPortalDisablesIt() { + portal.enqueue(jsonResponse(portalRegistryJson("route-1"))); + portal.enqueue(jsonResponse( + """ + { + "routes": [ { + "routeKey": "external", + "enabled": "N", + "routeRevision": "route-2", + "toolServices": [] + } ] + } + """)); + PortalToolRegistryClient client = client(); + client.refreshSourceRegistry(); + + boolean changed = client.refreshSourceRegistry(); + + assertThat(changed).isTrue(); + assertThat(client.isKnownRoute("external")).isFalse(); + assertThat(client.changedSourceRouteKeys()).containsExactly("external"); + assertThat(client.removedSourceRouteKeys()).containsExactly("external"); + } + + @Test + void reportsNoPortalChangeWhenRouteRevisionAndEndpointAreUnchanged() { + portal.enqueue(jsonResponse(portalRegistryJson("route-1"))); + portal.enqueue(jsonResponse(portalRegistryJson("route-1"))); + PortalToolRegistryClient client = client(); + + client.refreshSourceRegistry(); + boolean changed = client.refreshSourceRegistry(); + + assertThat(changed).isFalse(); + assertThat(client.changedSourceRouteKeys()).isEmpty(); + } + @Test void loadsEndpointRegistryFromRedisWhenPortalFailsOnColdStart() throws Exception { portal.enqueue(new MockResponse().setResponseCode(503)); @@ -164,14 +362,17 @@ class PortalToolRegistryClientTest { assertThat(toolServer.getRequestCount()).isEqualTo(1); } @Test - void keepsUsableRouteWhenEarlierRoutesHaveNoEndpointMetadata() { + void keepsInvalidEnabledRoutesEmptyWhileRefreshingUsableRoute() { portal.enqueue(portalRegistryWithOnlySysRouteUsable("portal-1")); toolServer.enqueue(manifestForBundle("was-sys", "sys.health")); PortalToolRegistryClient client = client(); Map> snapshots = client.fetchAllTools(); - assertThat(snapshots).containsOnlyKeys("sys"); + assertThat(snapshots).containsOnlyKeys("cus", "sal", "pro", "sys"); + assertThat(snapshots.get("cus")).isEmpty(); + assertThat(snapshots.get("sal")).isEmpty(); + assertThat(snapshots.get("pro")).isEmpty(); assertThat(snapshots.get("sys")) .extracting(ToolMetadata::name) .containsExactly("sys.health"); @@ -217,6 +418,21 @@ class PortalToolRegistryClientTest { assertThat(toolServer.takeRequest().getPath()).isEqualTo("/tool-service-manifest"); } + @Test + void preloadsRoutingHintSoFirstInitializeLookupUsesMemorySnapshot() throws Exception { + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-1"))); + PortalToolRegistryClient client = client(); + + client.preloadRoutingHints(); + List manifests = client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(manifests) + .singleElement() + .isEqualTo(OBJECT_MAPPER.readTree(routingManifest("external-tool-server", "revision-1"))); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + } + @Test void ignoresFailedRoutingManifestWithoutFailingInitializeHintCollection() { portal.enqueue(portalRegistry("portal-1")); @@ -229,6 +445,79 @@ class PortalToolRegistryClientTest { assertThat(toolServer.getRequestCount()).isEqualTo(1); } + @Test + void reusesRoutingHintSnapshotUntilRequestTimeTtlExpires() throws Exception { + AtomicLong clock = new AtomicLong(1_000L); + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-1"))); + toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-2"))); + PortalToolRegistryClient client = client(clock::get); + + List first = client.fetchRoutingManifests("external", "/tool-service-manifest"); + List cached = client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(first).singleElement().extracting(node -> node.path("revision").asText()) + .isEqualTo("revision-1"); + assertThat(cached).isEqualTo(first); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + + clock.addAndGet(300_000L); + List refreshed = client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(refreshed).singleElement().extracting(node -> node.path("revision").asText()) + .isEqualTo("revision-2"); + assertThat(toolServer.getRequestCount()).isEqualTo(2); + } + + @Test + void keepsLastGoodRoutingHintWhenTtlRefreshFails() throws Exception { + AtomicLong clock = new AtomicLong(1_000L); + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-1"))); + toolServer.enqueue(new MockResponse().setResponseCode(503)); + PortalToolRegistryClient client = client(clock::get); + + List first = client.fetchRoutingManifests("external", "/tool-service-manifest"); + clock.addAndGet(300_000L); + List fallback = client.fetchRoutingManifests("external", "/tool-service-manifest"); + List cachedFallback = client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(fallback).isEqualTo(first); + assertThat(cachedFallback).isEqualTo(first); + assertThat(toolServer.getRequestCount()).isEqualTo(2); + } + + @Test + void refreshesRoutingHintImmediatelyWhenPortalBundleEndpointChanges() throws Exception { + MockWebServer replacementToolServer = new MockWebServer(); + replacementToolServer.start(); + try { + AtomicLong clock = new AtomicLong(1_000L); + String currentDomain = toolServer.url("").toString().replaceAll("/+$", ""); + String replacementDomain = replacementToolServer.url("").toString().replaceAll("/+$", ""); + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-1"))); + PortalToolRegistryClient client = client(clock::get); + client.fetchRoutingManifests("external", "/tool-service-manifest"); + + portal.enqueue(jsonResponse( + portalRegistryJson("portal-2").replace(currentDomain, replacementDomain))); + replacementToolServer.enqueue( + jsonResponse(routingManifest("external-tool-server", "revision-2"))); + client.refreshSourceRegistry(); + + List refreshed = + client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(refreshed).singleElement().extracting(node -> node.path("revision").asText()) + .isEqualTo("revision-2"); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + assertThat(replacementToolServer.getRequestCount()).isEqualTo(1); + } finally { + replacementToolServer.shutdown(); + } + } + @Test void rejectsBlankRouteInsteadOfUsingConfiguredDefaultRoute() { PortalToolRegistryClient client = client(); @@ -243,11 +532,28 @@ class PortalToolRegistryClientTest { return client(Optional.empty()); } + private PortalToolRegistryClient client(LongSupplier currentTimeMillis) { + return client( + portal.url("/api/portal/registry").toString(), + Optional.empty(), + currentTimeMillis); + } + private PortalToolRegistryClient client(Optional redisPortalRegistryCache) { - return client(portal.url("/api/portal/registry").toString(), redisPortalRegistryCache); + return client( + portal.url("/api/portal/registry").toString(), + redisPortalRegistryCache, + System::currentTimeMillis); } private PortalToolRegistryClient client(String registryUrl, Optional redisPortalRegistryCache) { + return client(registryUrl, redisPortalRegistryCache, System::currentTimeMillis); + } + + private PortalToolRegistryClient client( + String registryUrl, + Optional redisPortalRegistryCache, + LongSupplier currentTimeMillis) { RestClient restClient = RestClient.builder() .requestFactory(new SimpleClientHttpRequestFactory()) .build(); @@ -272,7 +578,9 @@ class PortalToolRegistryClientTest { redisPortalRegistryCache, OBJECT_MAPPER, new DefaultResourceLoader(), - new LocalFixtureProperties(false, java.util.Map.of(), "")); + new LocalFixtureProperties(false, java.util.Map.of(), ""), + new AgentRoutingHintsProperties(true, "/tool-service-manifest", 300), + currentTimeMillis); } private MockResponse portalRegistry(String revision) { @@ -282,17 +590,18 @@ class PortalToolRegistryClientTest { private String portalRegistryJson(String revision) { return """ { - "registryRevision": "%s", "routes": [ { "routeKey": "external", + "enabled": "Y", + "routeRevision": "%s", "toolServices": [ { "serviceKey": "external-tool-server", "serviceDomain": "%s", "manifestPath": "/tool-manifest", "namePrefix": "external.", - "status": "ACTIVE" + "enabled": "Y" } ] } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryPreloaderTest.java similarity index 75% rename from src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java rename to src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryPreloaderTest.java index cbb9d5d..aab262d 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryPreloaderTest.java @@ -8,24 +8,25 @@ import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; import org.springframework.scheduling.annotation.Scheduled; -class ToolRegistryRefreshSchedulerTest { +class ToolRegistryPreloaderTest { @Test void preloadsRegistryOnceWhenApplicationIsReady() { ToolRegistryService service = mock(ToolRegistryService.class); when(service.refreshSourceRegistry()).thenReturn(true); - ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service); + ToolRegistryPreloader preloader = new ToolRegistryPreloader(service); - scheduler.preload(); + preloader.preload(); verify(service).warmStartFromSharedCache(); verify(service).refreshSourceRegistry(); verify(service).refreshKnownRoutes(); + verify(service).preloadRoutingHints(); } @Test void doesNotDeclareScheduledPollingMethods() { - assertThat(java.util.Arrays.stream(ToolRegistryRefreshScheduler.class.getDeclaredMethods()) + assertThat(java.util.Arrays.stream(ToolRegistryPreloader.class.getDeclaredMethods()) .noneMatch(method -> method.isAnnotationPresent(Scheduled.class))).isTrue(); } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java index 6cd66a4..1c2dc16 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryServiceTest.java @@ -21,6 +21,7 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -121,6 +122,38 @@ class ToolRegistryServiceTest { verify(client, times(1)).fetchTools(""); } + @Test + void sharesOnePortalRefreshAcrossConcurrentRequests() throws Exception { + ToolRegistryClient client = mock(ToolRegistryClient.class); + AtomicLong now = new AtomicLong(0); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(client.refreshSourceRegistry()) + .thenAnswer( + invocation -> { + entered.countDown(); + release.await(5, TimeUnit.SECONDS); + return false; + }); + ToolRegistryService service = + serviceWithClock(client, propertiesWithTtl(true, 30, 1), now); + service.refreshSourceRegistry(); + clearInvocations(client); + now.set(2_000); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(service::refreshSourceRegistryIfStale); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + var second = executor.submit(service::refreshSourceRegistryIfStale); + assertThat(second.isDone()).isFalse(); + release.countDown(); + + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + } + verify(client, times(1)).refreshSourceRegistry(); + } + @Test void propagatesSourceFailureWhenNoSnapshotExists() { ToolRegistryClient client = mock(ToolRegistryClient.class); @@ -191,6 +224,29 @@ class ToolRegistryServiceTest { assertThat(service.findEnabledTool("customer.search").version()).isEqualTo("1.0.0"); } + @Test + void throttlesRepeatedManifestRefreshForMissingToolByRoute() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + AtomicLong now = new AtomicLong(0); + when(client.fetchTools("external")).thenReturn(List.of(tool("http://cached-tool"))); + ToolRegistryService service = serviceWithClock(client, properties(false, false), now); + service.refresh("external"); + + now.set(6_000); + assertThatThrownBy(() -> service.findEnabledTool("external", "retired.tool")) + .isInstanceOf(JsonRpcException.class); + now.set(7_000); + assertThatThrownBy(() -> service.findEnabledTool("external", "retired.tool")) + .isInstanceOf(JsonRpcException.class); + + verify(client, times(2)).fetchTools("external"); + + now.set(11_001); + assertThatThrownBy(() -> service.findEnabledTool("external", "retired.tool")) + .isInstanceOf(JsonRpcException.class); + verify(client, times(3)).fetchTools("external"); + } + @Test void keepsIndependentSnapshotsPerRoute() { ToolRegistryClient client = mock(ToolRegistryClient.class); @@ -268,6 +324,77 @@ class ToolRegistryServiceTest { verify(client, times(2)).refreshSourceRegistry(); } + @Test + void refreshesOnlyTheRouteWhosePortalRevisionChanged() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + AtomicLong now = new AtomicLong(0); + McpProperties mcpProperties = propertiesWithTtl(true, 9_999, 1); + when(client.fetchTools("external")) + .thenReturn(List.of(tool("http://external-first"))); + when(client.fetchTools("business")) + .thenReturn(List.of(tool("http://business-first"))) + .thenReturn(List.of(tool("http://business-second"))); + when(client.refreshSourceRegistry()).thenReturn(true); + when(client.changedSourceRouteKeys()).thenReturn(Set.of("business")); + ToolRegistryService service = serviceWithClock(client, mcpProperties, now); + service.refresh("external"); + service.refresh("business"); + + now.set(2_000); + service.refreshSourceRegistryIfStale(); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://external-first"); + assertThat(service.listTools("business")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://business-second"); + verify(client, times(1)).fetchTools("external"); + verify(client, times(2)).fetchTools("business"); + } + + @Test + void removesMemorySnapshotWhenPortalRouteIsDisabled() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + when(client.fetchTools("external")).thenReturn(List.of(tool("http://external-tool"))); + when(client.refreshSourceRegistry()).thenReturn(true); + when(client.changedSourceRouteKeys()).thenReturn(Set.of("external")); + when(client.removedSourceRouteKeys()).thenReturn(Set.of("external")); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + service.refresh("external"); + assertThat(service.hasUsableSnapshot()).isTrue(); + + service.refreshSourceRegistry(); + + assertThat(service.hasUsableSnapshot()).isFalse(); + } + + @Test + void allowsFreshSnapshotWhenPortalRouteIsEnabledAgain() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + when(client.fetchTools("external")) + .thenReturn(List.of(tool("http://before-disable"))) + .thenReturn(List.of(tool("http://after-enable"))); + when(client.refreshSourceRegistry()).thenReturn(true, true); + when(client.changedSourceRouteKeys()).thenReturn(Set.of("external")); + when(client.removedSourceRouteKeys()) + .thenReturn(Set.of("external")) + .thenReturn(Set.of()); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + service.refresh("external"); + service.refreshSourceRegistry(); + + service.refreshSourceRegistry(); + List refreshed = service.refresh("external"); + + assertThat(refreshed) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://after-enable"); + } + @Test void refreshesAllPortalRoutesFromOneAggregateRegistrySnapshot() { ToolRegistryClient client = mock(ToolRegistryClient.class); diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java index 28dd0af..4da116f 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/toolclient/HttpToolClientTest.java @@ -23,11 +23,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolClientException; import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolRequest; import io.shinhanlife.dat.biz.mcp.toolclient.ToolClient.ToolResponse; +import java.net.InetAddress; import java.net.http.HttpClient; import java.time.Instant; import java.time.OffsetDateTime; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -57,7 +59,11 @@ class HttpToolClientTest { .setHeader("Content-Type", "application/json") .setBody("{\"customerName\":\"홍길동\"}")); HttpToolClient client = - new HttpToolClient(OBJECT_MAPPER, properties(false, false), HttpClient.newHttpClient()); + new HttpToolClient( + OBJECT_MAPPER, + properties(false, false), + HttpClient.newHttpClient(), + new HttpToolClient.CallerIdentity("10.20.30.40", "mcp-pod-1")); ToolRequest request = new ToolRequest( "customer.search", @@ -81,8 +87,8 @@ class HttpToolClientTest { assertThat(recorded.getHeader(HEADER_APP_CODE)).isEqualTo("DAH"); assertThat(recorded.getHeader(HEADER_PROJECT_CODE)).isEqualTo("AXHUB"); assertThat(recorded.getHeader(HEADER_USER_IP)).isEqualTo("10.10.10.1"); - assertThat(recorded.getHeader(HEADER_CALLER_IP)).isNotBlank(); - assertThat(recorded.getHeader(HEADER_CALLER_HOST)).isNotBlank(); + assertThat(recorded.getHeader(HEADER_CALLER_IP)).isEqualTo("10.20.30.40"); + assertThat(recorded.getHeader(HEADER_CALLER_HOST)).isEqualTo("mcp-pod-1"); assertThat(recorded.getHeader(HEADER_CHANNEL)).isEqualTo("MCP"); assertThat(recorded.getHeader(HEADER_AGENT_ID)).isEqualTo("agent-public-1"); assertThat(recorded.getHeader("mcp-session-id")).isEqualTo("session-1"); @@ -94,6 +100,54 @@ class HttpToolClientTest { .doesNotContain("arguments"); } + @Test + void usesDownwardApiIdentityWithoutResolvingLocalHost() { + AtomicInteger fallbackCalls = new AtomicInteger(); + + HttpToolClient.CallerIdentity identity = HttpToolClient.resolveCallerIdentity( + "10.30.40.50", + "mcp-pod-2", + () -> { + fallbackCalls.incrementAndGet(); + return InetAddress.getLoopbackAddress(); + }); + + assertThat(identity.ip()).isEqualTo("10.30.40.50"); + assertThat(identity.host()).isEqualTo("mcp-pod-2"); + assertThat(fallbackCalls).hasValue(0); + } + + @Test + void resolvesMissingCallerIdentityOnlyOnceAndKeepsKnownPodValue() throws Exception { + AtomicInteger fallbackCalls = new AtomicInteger(); + InetAddress fallback = InetAddress.getByAddress("local-mcp", new byte[] {10, 0, 0, 7}); + + HttpToolClient.CallerIdentity identity = HttpToolClient.resolveCallerIdentity( + "10.30.40.50", + " ", + () -> { + fallbackCalls.incrementAndGet(); + return fallback; + }); + + assertThat(identity.ip()).isEqualTo("10.30.40.50"); + assertThat(identity.host()).isEqualTo("local-mcp"); + assertThat(fallbackCalls).hasValue(1); + } + + @Test + void usesUnknownWhenCallerIdentityCannotBeResolved() { + HttpToolClient.CallerIdentity identity = HttpToolClient.resolveCallerIdentity( + null, + null, + () -> { + throw new IllegalStateException("host unavailable"); + }); + + assertThat(identity.ip()).isEqualTo("unknown"); + assertThat(identity.host()).isEqualTo("unknown"); + } + @Test void postsDirectArgumentsForEveryToolServer() throws Exception { server.enqueue(new MockResponse().setHeader("Content-Type", "application/json")