diff --git a/deploy/helm/mcp-server/templates/configmap.yaml b/deploy/helm/mcp-server/templates/configmap.yaml index be7b2bf..2ee5c7a 100644 --- a/deploy/helm/mcp-server/templates/configmap.yaml +++ b/deploy/helm/mcp-server/templates/configmap.yaml @@ -19,8 +19,7 @@ data: endpoint-path: {{ $deployment.publicPath | quote }} registry: - refreshIntervalSeconds: {{ .Values.mcp.refreshIntervalSeconds }} - refreshJitterSeconds: {{ .Values.mcp.refreshJitterSeconds }} + refreshTtlSeconds: {{ .Values.mcp.refreshTtlSeconds }} discovery: # 운영 profile은 Tool Service 매니페스트만 원천으로 쓴다. diff --git a/deploy/helm/mcp-server/values-prod.yaml b/deploy/helm/mcp-server/values-prod.yaml index 33d3197..56776ef 100644 --- a/deploy/helm/mcp-server/values-prod.yaml +++ b/deploy/helm/mcp-server/values-prod.yaml @@ -3,8 +3,8 @@ # replica는 배포 하나가 받는 트래픽 기준으로 잡는다. 업무 × 등급으로 나뉘어 있으므로 # 배포 하나가 받는 몫은 전체를 하나로 묶었을 때의 일부다. 등급별 기준은 아래가 정본이다. # -# 조회 부하 = replica 수 / 주기. 1:1이라 bundle 수는 항상 1이다(ADR-0007). -# 중요 등급 3 replica / 30초 = 배포당 초당 0.1회. Tool Service 한 대가 받는 몫이 그대로 이 값이다. +# Tool Service 매니페스트는 scheduler가 아니라 요청 시점 TTL 만료 시에만 다시 확인한다. +# 요청이 없는 동안에는 매니페스트 조회 부하가 발생하지 않는다. # # 중요 등급은 replica 2 이상과 PodDisruptionBudget이 필수다. # 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내린다. diff --git a/deploy/helm/mcp-server/values.yaml b/deploy/helm/mcp-server/values.yaml index 671b8d5..1ce66b5 100644 --- a/deploy/helm/mcp-server/values.yaml +++ b/deploy/helm/mcp-server/values.yaml @@ -125,10 +125,9 @@ image: pullPolicy: IfNotPresent mcp: - # Tool Service 매니페스트 조회 주기(초). - # 1:1이라 bundle 수가 항상 1이므로 조회 부하는 (replica 수 / 주기)다. - refreshIntervalSeconds: 30 - refreshJitterSeconds: 5 + # 요청 시점에 Tool Service 매니페스트를 다시 확인할 TTL(초). + # 요청이 없는 동안에는 Tool Service를 호출하지 않는다. + refreshTtlSeconds: 300 toolService: # MCP와 같은 namespace에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다. diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java index 9deae55..21db174 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/McpServerApplication.java @@ -6,7 +6,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; -import org.springframework.scheduling.annotation.EnableScheduling; /** * @package io.shinhanlife.dat.biz.mcp @@ -25,7 +24,6 @@ import org.springframework.scheduling.annotation.EnableScheduling; */ @SpringBootApplication @ConfigurationPropertiesScan -@EnableScheduling public class McpServerApplication { /** 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 new file mode 100644 index 0000000..ea49f7c --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/AgentRoutingHintsProperties.java @@ -0,0 +1,36 @@ +package io.shinhanlife.dat.biz.mcp.config; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * @package io.shinhanlife.dat.biz.mcp.config + * @className AgentRoutingHintsProperties + * @description initialize 응답에 Agent 선택 최적화용 routing manifest를 포함할지 제어하는 설정 계약입니다. 직접 HTTP 요청을 처리하지 않고 {@code InitializeHandler}와 Registry client가 Tool Server의 설명 API 호출 여부와 경로를 판단할 때 사용합니다. + * @author j.h.w + * @create 2026.09.02 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.02   j.h.w         최초생성
+ *
+ * 
+ */ +@Validated +@ConfigurationProperties(prefix = "mcp.agent-routing-hints") +public record AgentRoutingHintsProperties(boolean enabled, @NotBlank String manifestPath) { + + /** + * routing manifest 경로가 생략된 설정에서도 기본 Tool Server API 경로를 사용하도록 보정합니다. 값은 항상 slash로 시작하게 만들어 service domain 뒤에 안전하게 붙일 수 있게 합니다. + */ + public AgentRoutingHintsProperties { + if (manifestPath == null || manifestPath.isBlank()) { + manifestPath = "/tool-service-manifest"; + } else if (!manifestPath.startsWith("/")) { + manifestPath = "/" + manifestPath; + } + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java index 651bba1..0ae40c4 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/config/McpProperties.java @@ -51,6 +51,9 @@ public record McpProperties( */ public McpProperties { bundles = bundles == null ? List.of() : List.copyOf(bundles); + registry = registry == null + ? new Registry("file:./config/local-core-tools-manifest-sample-v1.json", 300, 5) + : registry; toolClient = toolClient == null ? ToolClient.defaults() : toolClient; portal = portal == null ? new Portal(false, "", "", 300) : portal; } @@ -132,11 +135,11 @@ public record McpProperties( } /** - * local JSON fixture 위치와 Tool Service refresh 주기·분산 지연 설정입니다. + * local JSON fixture 위치와 요청 시점 Tool Service refresh TTL·분산 지연 설정입니다. */ public record Registry( @NotBlank String localToolFile, - @Min(1) long refreshIntervalSeconds, + @Min(1) long refreshTtlSeconds, @Min(0) long refreshJitterSeconds) { } @@ -245,7 +248,7 @@ public record McpProperties( } /** - * Tool Service bundle 매니페스트 주기 조회의 timeout과 상한 정책 설정입니다. 상한값은 잘못 구성된 bundle 하나가 전체 카탈로그를 부풀리거나 Tool timeout을 무한정 늘리는 것을 막는 방어선입니다. + * Tool Service bundle 매니페스트 조회의 timeout과 상한 정책 설정입니다. 상한값은 잘못 구성된 bundle 하나가 전체 카탈로그를 부풀리거나 Tool timeout을 무한정 늘리는 것을 막는 방어선입니다. */ public record Discovery( boolean enabled, @@ -258,10 +261,9 @@ public record McpProperties( } /** - * 포털이 소유한 Tool Service registry 조회 설정입니다. - * MCP 요청을 직접 처리하지 않고 배경 refresh가 route별 Tool Service 위치와 revision을 읽을 때 사용합니다. + * 포털이 소유한 Tool Service registry 조회 설정입니다. MCP 요청을 직접 처리하지 않고 기동 preload와 요청 시점 TTL refresh가 route별 Tool Service 위치와 revision을 읽을 때 사용합니다. */ - public record Portal(boolean enabled, String routeKey, String registryUrl, @Min(1) long refreshIntervalSeconds) { + public record Portal(boolean enabled, String routeKey, String registryUrl, @Min(1) long refreshTtlSeconds) { } /** 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 69af1f3..1c8c5d3 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 @@ -1,10 +1,17 @@ package io.shinhanlife.dat.biz.mcp.method; +import com.fasterxml.jackson.databind.JsonNode; import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dat.biz.mcp.config.AgentRoutingHintsProperties; import io.shinhanlife.dat.biz.mcp.config.McpProperties; import io.shinhanlife.dat.biz.mcp.context.McpRequestContext; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcResponse; +import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryClient; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** @@ -26,14 +33,33 @@ import org.springframework.stereotype.Component; public class InitializeHandler implements McpMethodHandlerRegistry.Handler { private final McpProperties properties; + private final AgentRoutingHintsProperties routingHintsProperties; + private final ToolRegistryClient registryClient; /** - * initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다. + * initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다. 테스트 호환을 위한 생성자이며 Agent routing hint는 비활성화합니다. * * @param properties MCP 설정 정보입니다. */ public InitializeHandler(McpProperties properties) { + this(properties, new AgentRoutingHintsProperties(false, "/tool-service-manifest"), null); + } + + /** + * initialize 응답에 사용할 서버 정보, protocol 설정, Agent routing hint 조회 port를 주입받습니다. routing hint가 켜진 경우에만 Registry client를 통해 현재 route의 Tool Server 설명 manifest를 읽습니다. + * + * @param properties MCP 설정 정보입니다. + * @param routingHintsProperties Agent routing hint 설정 정보입니다. + * @param registryClient Tool Server routing manifest 조회 port입니다. + */ + @Autowired + public InitializeHandler( + McpProperties properties, + AgentRoutingHintsProperties routingHintsProperties, + ToolRegistryClient registryClient) { this.properties = properties; + this.routingHintsProperties = routingHintsProperties; + this.registryClient = registryClient; } /** @@ -68,11 +94,33 @@ public class InitializeHandler implements McpMethodHandlerRegistry.Handler { .title(serverTitle) .build(); McpSchema.ServerCapabilities capabilities = - McpSchema.ServerCapabilities.builder().tools(true).build(); - McpSchema.InitializeResult result = + McpSchema.ServerCapabilities.builder().tools(false).build(); + McpSchema.InitializeResult.Builder builder = McpSchema.InitializeResult.builder( - properties.protocol().preferredVersion(), capabilities, serverInfo) - .build(); + properties.protocol().preferredVersion(), capabilities, serverInfo); + Map meta = routingHintMeta(routeKey); + if (!meta.isEmpty()) { + builder.meta(meta); + } + McpSchema.InitializeResult result = builder.build(); return JsonRpcResponse.success(request.id(), result); } + + /** + * initialize 응답의 `_meta`에 들어갈 Agent routing hint wrapper를 만듭니다. Tool Server가 준 routing manifest JSON은 변환하지 않고 {@code toolServers} 배열로 감싸며, 기능이 꺼져 있거나 route가 없으면 표준 initialize 응답만 유지합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return Agent Builder로 공개할 `_meta` map입니다. + */ + private Map routingHintMeta(String routeKey) { + if (!routingHintsProperties.enabled() || routeKey == null || registryClient == null) { + return Map.of(); + } + List routingManifests = + registryClient.fetchRoutingManifests(routeKey, routingHintsProperties.manifestPath()); + Map meta = new LinkedHashMap<>(); + meta.put("routeKey", routeKey); + meta.put("toolServers", routingManifests); + return meta; + } } 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 ca0cc5e..a93b8fa 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 @@ -9,6 +9,8 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleResult; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -140,6 +142,28 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } } + /** + * 현재 route에 연결된 Tool Server별 routing manifest API를 호출해 원문 JSON을 모읍니다. 이 정보는 Agent의 MCP 선택 최적화에만 쓰이며, 일부 Tool Server 호출이 실패해도 initialize 응답 자체가 실패하지 않도록 성공한 응답만 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. + */ + @Override + public List fetchRoutingManifests(String routeKey, String manifestPath) { + String normalizedRouteKey = normalizeRouteKey(routeKey); + ensurePortalRegistryLoaded(); + List bundles = bundlesByRoute.get(normalizedRouteKey); + if (bundles == null || bundles.isEmpty()) { + return List.of(); + } + List manifests = new ArrayList<>(); + for (Bundle bundle : bundles) { + fetchRoutingManifest(bundle, manifestPath).ifPresent(manifests::add); + } + return List.copyOf(manifests); + } + /** * Portal API를 먼저 조회하고, 실패 시 기존 memory endpoint snapshot 또는 Redis fallback으로 대체합니다. 이미 memory가 있으면 Redis를 읽지 않고 기존 snapshot을 유지하며, cold start처럼 memory가 없을 때만 Redis registry JSON을 마지막 fallback으로 사용합니다. * @@ -289,6 +313,73 @@ public class PortalToolRegistryClient implements ToolRegistryClient { return merge(results); } + /** + * Tool Server 한 대의 routing manifest API를 호출합니다. 응답 JSON shape는 Tool Server와 Agent Builder의 계약이므로 MCP가 필드명을 변환하지 않고 원문 JSON tree를 그대로 반환합니다. + * + * @param bundle 입력값입니다. + * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. + * @return 조회된 선택값을 반환합니다. + */ + private Optional fetchRoutingManifest(Bundle bundle, String manifestPath) { + try { + return Optional.of(objectMapper.readTree(fetchRoutingManifestBody(bundle, manifestPath))); + } catch (RuntimeException exception) { + log.warn( + "Tool Service routing manifest ignored: bundleId={}, reason={}", + bundle.id(), + exception.getClass().getSimpleName()); + return Optional.empty(); + } catch (IOException exception) { + log.warn( + "Tool Service routing manifest ignored: bundleId={}, reason={}", + bundle.id(), + exception.getClass().getSimpleName()); + return Optional.empty(); + } + } + + /** + * Tool Server의 routing manifest 응답을 설정된 최대 byte 안에서 문자열로 읽습니다. Agent 최적화용 설명 API라도 외부 응답이므로 기존 manifest 크기 상한을 적용해 과도한 응답이 initialize 처리 메모리를 점유하지 않게 합니다. + * + * @param bundle 입력값입니다. + * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. + * @return 처리된 문자열 값을 반환합니다. + */ + private String fetchRoutingManifestBody(Bundle bundle, String manifestPath) { + int maxBytes = properties.discovery().maxManifestBytes(); + return restClient.get() + .uri(routingManifestUri(bundle.baseEndpoint(), manifestPath)) + .exchange( + (request, response) -> { + if (response.getStatusCode().isError()) { + throw new IllegalStateException( + "routing manifest returned HTTP " + response.getStatusCode().value()); + } + try (InputStream input = response.getBody()) { + byte[] bytes = input.readNBytes(maxBytes + 1); + if (bytes.length > maxBytes) { + throw new IllegalStateException("routing manifest exceeds " + maxBytes + " bytes"); + } + return new String(bytes, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("unable to read routing manifest response", exception); + } + }); + } + + /** + * 포털이 제공한 Tool Server service domain과 설정된 routing manifest path를 HTTP 호출 주소로 조합합니다. path가 slash 없이 들어와도 같은 주소가 되도록 정규화합니다. + * + * @param serviceDomain Tool Server service domain입니다. + * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. + * @return 처리 결과를 반환합니다. + */ + private URI routingManifestUri(String serviceDomain, String manifestPath) { + String base = trimTrailingSlash(serviceDomain); + String path = normalizePath(manifestPath); + return URI.create(base + path); + } + /** * 최초 기동 또는 cache가 비어 있는 요청 시점에 포털 registry를 조회합니다. 이후 manifest 주기 refresh는 저장된 endpoint 목록만 사용하므로 포털 API와 Tool Server manifest 호출 주기를 분리합니다. */ diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java index 6709d2b..a965047 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/RedisToolRegistryCache.java @@ -58,7 +58,7 @@ public class RedisToolRegistryCache { this.cacheKeyPrefix = "%s:%s:%s:route" .formatted(properties.redis().keyPrefix(), properties.identity(), CACHE_SCHEMA_VERSION); - this.ttl = Duration.ofSeconds(Math.max(30, properties.registry().refreshIntervalSeconds() * 3)); + this.ttl = Duration.ofSeconds(Math.max(30, properties.registry().refreshTtlSeconds() * 3)); } /** 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 d5a7555..5230b9b 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 @@ -1,5 +1,6 @@ package io.shinhanlife.dat.biz.mcp.registry; +import com.fasterxml.jackson.databind.JsonNode; import java.util.List; import java.util.Map; @@ -56,6 +57,17 @@ public interface ToolRegistryClient { return true; } + /** + * Agent가 MCP 등록·선택 최적화에 사용할 route별 Tool Server routing manifest 원문을 읽습니다. 지원하지 않는 구현은 빈 목록을 반환하며, 호출자는 initialize 자체를 실패시키지 않고 `_meta`를 생략할 수 있습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param manifestPath Tool Server에 붙일 routing manifest API 경로입니다. + * @return 조회 또는 변환된 목록 정보를 반환합니다. + */ + default List fetchRoutingManifests(String routeKey, String manifestPath) { + return List.of(); + } + /** * 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/ToolRegistryRefreshScheduler.java index 3725298..775b293 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshScheduler.java @@ -1,18 +1,15 @@ package io.shinhanlife.dat.biz.mcp.registry; -import java.util.concurrent.TimeUnit; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** * @package io.shinhanlife.dat.biz.mcp.registry * @className ToolRegistryRefreshScheduler - * @description 시작 시점과 설정된 주기에 Tool Registry cache를 선행 갱신하는 scheduler입니다. + * @description 시작 시점에만 Tool Registry cache를 선행 갱신하는 preload 컴포넌트입니다. * @author j.h.w * @create 2026.08.06 * @@ -41,7 +38,7 @@ public class ToolRegistryRefreshScheduler { } /** - * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다. + * 애플리케이션 준비 직후 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신 상태로 교체합니다. 이후 갱신은 scheduler가 아니라 요청 시점 TTL 확인에서 수행합니다. 각 단계가 실패해도 애플리케이션은 계속 기동합니다. * * @return 처리 결과를 반환합니다. */ @@ -75,38 +72,7 @@ public class ToolRegistryRefreshScheduler { } /** - * 설정된 간격마다 Tool Service manifest를 다시 읽어 cache snapshot을 갱신합니다. 첫 scheduled 실행에는 bounded random jitter를 더해 동시에 기동한 replica의 조회 시점을 분산합니다. - * - * @return 처리 결과를 반환합니다. - */ - @Scheduled( - fixedDelayString = "${mcp.registry.refresh-interval-seconds:30}", - initialDelayString = - "#{${mcp.registry.refresh-interval-seconds:30}" - + " + T(java.util.concurrent.ThreadLocalRandom).current()" - + ".nextLong(0, ${mcp.registry.refresh-jitter-seconds:5} + 1)}", - timeUnit = TimeUnit.SECONDS) - public void scheduledManifestRefresh() { - safeManifestRefresh("scheduled"); - } - - /** - * 설정된 간격마다 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 갱신합니다. manifest 조회와 snapshot 교체는 수행하지 않으며, 실패하더라도 기존 endpoint 목록과 snapshot은 유지됩니다. - * - * @return 처리 결과를 반환합니다. - */ - @Scheduled( - fixedDelayString = "${mcp.portal.refresh-interval-seconds:300}", - initialDelayString = "${mcp.portal.refresh-interval-seconds:300}", - timeUnit = TimeUnit.SECONDS) - public void scheduledPortalRefresh() { - if (safePortalRefresh("scheduled")) { - safeManifestRefresh("portal-change"); - } - } - - /** - * refresh 실패를 로그로 격리하여 scheduler나 애플리케이션이 중단되지 않게 합니다. + * manifest preload 실패를 로그로 격리하여 애플리케이션이 중단되지 않게 합니다. * * @param trigger 입력값입니다. */ @@ -114,7 +80,7 @@ public class ToolRegistryRefreshScheduler { try { registryService.refreshKnownRoutes(); } catch (RuntimeException exception) { - // Cache preload/refresh is best-effort; request-time direct lookup remains available. + // Cache preload is best-effort; request-time direct lookup remains available. logger.warn( "Tool manifest refresh failed: trigger={}, reason={}, message={}", trigger, 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 9b4a5d1..ea4248a 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 @@ -2,6 +2,7 @@ package io.shinhanlife.dat.biz.mcp.registry; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dat.biz.mcp.config.McpProperties; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dat.biz.mcp.transport.http.McpRouteKeyValidator; @@ -14,6 +15,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; 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; @@ -44,9 +46,14 @@ public class ToolRegistryService implements McpRouteKeyValidator { private final Optional redisCache; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; + private final McpProperties properties; + private final LongSupplier currentTimeMillis; private final ConcurrentMap> snapshotsByRoute = new ConcurrentHashMap<>(); private final ConcurrentMap>> refreshInFlightByRoute = new ConcurrentHashMap<>(); + private final ConcurrentMap manifestRefreshAttemptsByRoute = new ConcurrentHashMap<>(); + private volatile long portalRefreshAttemptMillis = Long.MIN_VALUE; + private volatile long portalChangeMillis = Long.MIN_VALUE; /** * 원천 Registry와 선택적 Redis 공유 cache를 주입받습니다. 테스트에서 주로 사용하며 이벤트 발행자와 JSON mapper는 기본값으로 구성합니다. @@ -56,7 +63,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { */ public ToolRegistryService( ToolRegistryClient registryClient, Optional redisCache) { - this(registryClient, redisCache, event -> { }, new ObjectMapper()); + this(registryClient, redisCache, event -> { }, new ObjectMapper(), defaultProperties(), System::currentTimeMillis); } /** @@ -70,28 +77,51 @@ public class ToolRegistryService implements McpRouteKeyValidator { ToolRegistryClient registryClient, Optional redisCache, ApplicationEventPublisher eventPublisher) { - this(registryClient, redisCache, eventPublisher, new ObjectMapper()); + this(registryClient, redisCache, eventPublisher, new ObjectMapper(), defaultProperties(), System::currentTimeMillis); } /** - * 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, 검증 로그용 JSON 직렬화 도구를 주입받습니다. 운영 코드에서 사용하는 생성자이며 route별 snapshot 갱신과 Redis fallback 정책을 이 서비스 안에 모읍니다. + * 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, 검증 로그용 JSON 직렬화 도구와 TTL 설정을 주입받습니다. 운영 코드에서 사용하는 생성자이며 route별 snapshot 갱신과 Redis fallback 정책을 이 서비스 안에 모읍니다. * * @param registryClient 협력 객체입니다. * @param redisCache 협력 객체입니다. * @param eventPublisher 협력 객체입니다. * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. - * @return 처리 결과를 반환합니다. + * @param properties MCP 설정 정보입니다. */ @Autowired public ToolRegistryService( ToolRegistryClient registryClient, Optional redisCache, ApplicationEventPublisher eventPublisher, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + McpProperties properties) { + this(registryClient, redisCache, eventPublisher, objectMapper, properties, System::currentTimeMillis); + } + + /** + * 테스트에서 시간 흐름을 제어할 수 있도록 clock supplier까지 주입받는 내부 생성자입니다. 운영에서는 현재 시각 supplier를 사용하고, 테스트에서는 TTL 만료 여부를 결정적으로 검증합니다. + * + * @param registryClient 협력 객체입니다. + * @param redisCache 협력 객체입니다. + * @param eventPublisher 협력 객체입니다. + * @param objectMapper JSON 직렬화와 역직렬화에 사용할 mapper입니다. + * @param properties MCP 설정 정보입니다. + * @param currentTimeMillis 현재 시각 밀리초 supplier입니다. + */ + ToolRegistryService( + ToolRegistryClient registryClient, + Optional redisCache, + ApplicationEventPublisher eventPublisher, + ObjectMapper objectMapper, + McpProperties properties, + LongSupplier currentTimeMillis) { this.registryClient = registryClient; this.redisCache = redisCache; this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; + this.properties = properties; + this.currentTimeMillis = currentTimeMillis; } /** @@ -111,6 +141,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { */ public List listTools(String routeKey) { String normalizedRouteKey = normalizeRouteKey(routeKey); + refreshIfStale(normalizedRouteKey); List memory = snapshotsByRoute.get(normalizedRouteKey); if (memory != null) { return memory; @@ -136,7 +167,10 @@ public class ToolRegistryService implements McpRouteKeyValidator { } redisCache .flatMap(cache -> cache.loadSnapshot("")) - .ifPresent(tools -> snapshotsByRoute.putIfAbsent("", List.copyOf(tools))); + .ifPresent(tools -> { + snapshotsByRoute.putIfAbsent("", List.copyOf(tools)); + manifestRefreshAttemptsByRoute.putIfAbsent("", currentTimeMillis.getAsLong()); + }); } /** @@ -200,6 +234,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { if (running != null) { return awaitRefresh(running); } + manifestRefreshAttemptsByRoute.put(normalizedRouteKey, currentTimeMillis.getAsLong()); try { List tools = refreshOnce(normalizedRouteKey); candidate.complete(tools); @@ -213,13 +248,15 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 현재 memory에 알려진 모든 route를 주기적으로 갱신합니다. Registry client가 전체 route snapshot을 제공하면 한 번에 반영하고, 그렇지 않으면 기존 route별 refresh를 수행합니다. + * 현재 memory에 알려진 모든 route를 요청 시점 또는 기동 preload에서 갱신합니다. Registry client가 전체 route snapshot을 제공하면 한 번에 반영하고, 그렇지 않으면 기존 route별 refresh를 수행합니다. */ public void refreshKnownRoutes() { Map> snapshots = registryClient.fetchAllTools(); if (!snapshots.isEmpty()) { snapshotsByRoute.keySet().removeIf(routeKey -> !snapshots.containsKey(routeKey)); + long now = currentTimeMillis.getAsLong(); snapshots.forEach(this::replaceSnapshot); + snapshots.keySet().forEach(routeKey -> manifestRefreshAttemptsByRoute.put(routeKey, now)); return; } List routeKeys = snapshotsByRoute.isEmpty() @@ -229,12 +266,66 @@ public class ToolRegistryService implements McpRouteKeyValidator { } /** - * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다. Tool manifest 조회와 memory snapshot 교체는 여기서 수행하지 않고 scheduler의 별도 주기에서 처리합니다. + * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다. Tool manifest 조회와 memory snapshot 교체는 여기서 직접 수행하지 않고, 변경 여부와 변경 시각을 남겨 같은 요청의 route manifest 갱신 판단에 사용합니다. * * @return 조건 충족 여부를 반환합니다. */ public boolean refreshSourceRegistry() { - return registryClient.refreshSourceRegistry(); + try { + boolean changed = registryClient.refreshSourceRegistry(); + if (changed) { + portalChangeMillis = currentTimeMillis.getAsLong(); + } + return changed; + } finally { + portalRefreshAttemptMillis = currentTimeMillis.getAsLong(); + } + } + + /** + * 요청 시점에 Portal registry TTL이 만료되었으면 endpoint 목록을 best-effort로 갱신합니다. 실패해도 기존 in-memory endpoint snapshot은 Registry client의 fallback 정책에 맡기며, 같은 TTL 구간에서 요청마다 Portal을 반복 호출하지 않도록 마지막 시도 시각을 남깁니다. + */ + @Override + public synchronized void refreshSourceRegistryIfStale() { + boolean portalDue = isPortalRefreshDue(); + log.info( + "TEMP_PORTAL_TTL_REFRESH_DECISION phase=route_validation portalDue={} action={}", + portalDue, + portalDue ? "refresh_portal_registry" : "skip"); + if (!portalDue) { + return; + } + refreshSourceRegistry(); + } + + /** + * 요청 시점에 Portal registry와 지정 route의 Tool manifest TTL을 확인해 필요한 원천만 갱신합니다. Portal endpoint 목록이 변경되면 같은 요청에서 route manifest도 즉시 다시 읽고, refresh 실패 시에는 기존 last-good snapshot을 유지합니다. + * + * @param routeKey 처리 대상 route key입니다. + */ + public void refreshIfStale(String routeKey) { + String normalizedRouteKey = normalizeRouteKey(routeKey); + boolean portalDue = isPortalRefreshDue(); + boolean portalChanged = false; + if (portalDue) { + portalChanged = refreshSourceRegistry(); + } + boolean portalChangeNewerThanManifest = isPortalChangeNewerThanManifest(normalizedRouteKey); + boolean manifestDue = isManifestRefreshDue(normalizedRouteKey); + boolean manifestRefresh = portalChanged || portalChangeNewerThanManifest || manifestDue; + log.info( + "TEMP_TTL_REFRESH_DECISION routeKey={} portalDue={} portalChanged={} " + + "portalChangeNewerThanManifest={} manifestDue={} manifestRefresh={} action={}", + normalizedRouteKey, + portalDue, + portalChanged, + portalChangeNewerThanManifest, + manifestDue, + manifestRefresh, + manifestRefresh ? "refresh_tool_manifest" : "use_memory_snapshot"); + if (manifestRefresh) { + refresh(normalizedRouteKey); + } } /** @@ -273,6 +364,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { redisCache.flatMap(cache -> cache.loadSnapshot(routeKey)); if (shared.isPresent()) { snapshotsByRoute.put(routeKey, List.copyOf(shared.get())); + manifestRefreshAttemptsByRoute.put(routeKey, currentTimeMillis.getAsLong()); return shared.get(); } throw exception; @@ -395,4 +487,78 @@ public class ToolRegistryService implements McpRouteKeyValidator { private String normalizeRouteKey(String routeKey) { return routeKey == null ? "" : routeKey.trim(); } + + /** + * Portal registry 조회가 필요한 시점인지 TTL 설정과 마지막 시도 시각으로 판단합니다. Portal 모드가 아니면 항상 false를 반환합니다. + * + * @return Portal registry refresh 필요 여부를 반환합니다. + */ + private boolean isPortalRefreshDue() { + if (properties.portal() == null || !properties.portal().enabled()) { + return false; + } + return isExpired(portalRefreshAttemptMillis, properties.portal().refreshTtlSeconds()); + } + + /** + * 지정 route의 Tool manifest 조회가 필요한 시점인지 판단합니다. 아직 snapshot이 없으면 TTL과 무관하게 조회가 필요하며, snapshot이 있으면 마지막 manifest 조회 시도 이후 TTL이 지났을 때만 true를 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return Tool manifest refresh 필요 여부를 반환합니다. + */ + private boolean isManifestRefreshDue(String routeKey) { + if (!snapshotsByRoute.containsKey(routeKey)) { + return true; + } + Long lastAttemptMillis = manifestRefreshAttemptsByRoute.get(routeKey); + return lastAttemptMillis == null || isExpired(lastAttemptMillis, properties.registry().refreshTtlSeconds()); + } + + /** + * Portal endpoint 목록 변경이 해당 route의 마지막 manifest 조회보다 나중에 발생했는지 확인합니다. 필터 단계에서 Portal TTL refresh가 먼저 수행된 요청도 controller 단계에서 manifest 갱신을 놓치지 않게 합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return Portal 변경 이후 manifest 재조회가 필요한지 여부를 반환합니다. + */ + private boolean isPortalChangeNewerThanManifest(String routeKey) { + long changedAt = portalChangeMillis; + Long manifestAttemptMillis = manifestRefreshAttemptsByRoute.get(routeKey); + return changedAt != Long.MIN_VALUE + && (manifestAttemptMillis == null || changedAt > manifestAttemptMillis); + } + + /** + * 마지막 시도 시각과 TTL 초 값을 비교해 만료 여부를 계산합니다. + * + * @param lastAttemptMillis 마지막 refresh 시도 시각입니다. + * @param ttlSeconds TTL 초 값입니다. + * @return TTL 만료 여부를 반환합니다. + */ + private boolean isExpired(long lastAttemptMillis, long ttlSeconds) { + if (lastAttemptMillis == Long.MIN_VALUE) { + return true; + } + long elapsedMillis = currentTimeMillis.getAsLong() - lastAttemptMillis; + return elapsedMillis >= ttlSeconds * 1_000L; + } + + /** + * 테스트와 호환 생성자에서 사용할 최소 MCP 설정을 만듭니다. 운영 원천 주소를 만들지 않고, 요청 시점 TTL과 Tool client 기본값처럼 내부 동작에 필요한 안전한 기본값만 제공합니다. + * + * @return MCP 기본 설정을 반환합니다. + */ + private static McpProperties defaultProperties() { + return new McpProperties( + "mcp-test", + "/mcp", + null, + new McpProperties.Registry("file:./config/local-core-tools-manifest-sample-v1.json", 300, 5), + McpProperties.ToolClient.defaults(), + null, + null, + null, + null, + new McpProperties.Portal(false, "", "", 300), + List.of()); + } } diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java index 9c4bfa7..fd9bd9b 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpExchangeFilter.java @@ -197,12 +197,35 @@ public class McpExchangeFilter implements Filter { traceLogger.error( "mcp_http_request_rejected", exception, + "httpMethod", + request.getMethod(), + "path", + request.getRequestURI(), + "remoteAddr", + request.getRemoteAddr(), + "forwardedFor", + headerOrEmpty(request, "X-Forwarded-For"), + "userAgent", + headerOrEmpty(request, "User-Agent"), "maxBodyBytes", properties.trace().maxBodyBytes()); writeJsonRpcError(response, JsonRpcErrorCode.INVALID_REQUEST, exception.getMessage()); } catch (JsonRpcException exception) { traceLogger.error( - "mcp_http_request_rejected", exception, "errorCode", exception.errorCode().code()); + "mcp_http_request_rejected", + exception, + "httpMethod", + request.getMethod(), + "path", + request.getRequestURI(), + "remoteAddr", + request.getRemoteAddr(), + "forwardedFor", + headerOrEmpty(request, "X-Forwarded-For"), + "userAgent", + headerOrEmpty(request, "User-Agent"), + "errorCode", + exception.errorCode().code()); writeJsonRpcError(response, exception.errorCode(), exception.errorData()); } catch (IOException exception) { // 여기까지 온 IOException은 대개 "쓰려는데 상대가 이미 끊었다"(broken pipe)다. @@ -228,6 +251,7 @@ public class McpExchangeFilter implements Filter { if (properties.portal() == null || !properties.portal().enabled() || isFixedEndpointRequest(request)) { return; } + routeKeyValidator.refreshSourceRegistryIfStale(); if (!routeKeyValidator.isKnownRoute(context.routeKey())) { throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "route key is not registered"); } @@ -305,7 +329,7 @@ public class McpExchangeFilter implements Filter { throws IOException { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - objectMapper.writeValue( + objectMapper.writeValue( response.getOutputStream(), protocolVersionErrorBody(context, exception)); } @@ -323,6 +347,18 @@ public class McpExchangeFilter implements Filter { } } + /** + * 거절 로그에서 호출 주체를 추적할 수 있도록 선택 HTTP header를 안전하게 읽습니다. header가 없으면 빈 문자열을 반환해 trace 로그 포맷을 유지합니다. + * + * @param request 처리할 요청 정보입니다. + * @param name 대상 이름입니다. + * @return 처리된 문자열 값을 반환합니다. + */ + private String headerOrEmpty(HttpServletRequest request, String name) { + String value = request.getHeader(name); + return StringUtils.hasText(value) ? value : ""; + } + /** * protocol version transport 오류 body를 구성합니다. {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다. * diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java index fe36dae..690f754 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/transport/http/McpRouteKeyValidator.java @@ -18,7 +18,13 @@ package io.shinhanlife.dat.biz.mcp.transport.http; public interface McpRouteKeyValidator { /** - * 주어진 route key가 현재 허용 가능한지 확인합니다. 구현체는 요청 경로에서 원격 Portal이나 Redis를 새로 호출하지 않고, 이미 적재된 메모리 상태만 확인해야 합니다. + * 주어진 route key가 현재 허용 가능한지 확인하기 전에 route 원천 TTL이 만료되었으면 best-effort refresh를 수행합니다. 구현체는 refresh 실패 시 기존 last-good endpoint snapshot을 유지해야 합니다. + */ + default void refreshSourceRegistryIfStale() { + } + + /** + * 주어진 route key가 현재 허용 가능한지 확인합니다. 구현체는 refresh 이후에도 이미 적재된 메모리 상태만 확인해야 하며, 등록되지 않은 route는 controller 진입 전에 거부되게 합니다. * * @param routeKey 처리 대상 route key입니다. * @return 조건 충족 여부를 반환합니다. diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 30457db..a99a3f4 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,12 +1,12 @@ mcp: registry: - refresh-interval-seconds: 10 + refresh-ttl-seconds: 10 discovery: enabled: false portal: enabled: true registry-url: file:./config/local-toolserver-info-sample-v1.json - refresh-interval-seconds: 15 + refresh-ttl-seconds: 15 bundles: [] redis: enabled: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 955443a..86bb7fa 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,8 +15,8 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - # Redis is a shared cache, never the source of truth. A slow Redis must not slow the - # background refresh, so these timeouts are deliberately far shorter than the Tool timeouts. + # Redis is a shared cache, never the source of truth. A slow Redis must not delay + # fallback cache access, so these timeouts are deliberately far shorter than the Tool timeouts. connect-timeout: 200ms timeout: 200ms @@ -56,8 +56,7 @@ mcp: registry: # local profile uses this file instead of opening a separate Registry HTTP port. local-tool-file: ${MCP_LOCAL_TOOL_REGISTRY_FILE:file:./config/local-core-tools-manifest-sample-v1.json} - refresh-interval-seconds: ${MCP_REGISTRY_REFRESH_INTERVAL_SECONDS:10} - refresh-jitter-seconds: 5 + refresh-ttl-seconds: ${MCP_REGISTRY_REFRESH_TTL_SECONDS:300} tool-client: connect-timeout-millis: 1000 read-timeout-millis: 5000 @@ -79,7 +78,7 @@ mcp: - 503 - 504 redis: - enabled: true + enabled: ${MCP_REDIS_ENABLED:false} key-prefix: axhub:mcp:tools # Portal writes the endpoint registry JSON here. MCP reads it only when the # Portal API is unavailable and no in-memory endpoint snapshot exists. @@ -96,10 +95,12 @@ mcp: max-tool-timeout-millis: 30000 portal: enabled: ${MCP_PORTAL_ENABLED:false} - route-key: ${MCP_PORTAL_ROUTE_KEY:} # HTTP(S) Portal API or local Spring resource location such as file:./config/local-toolserver-info-sample-v1.json. registry-url: ${MCP_PORTAL_REGISTRY_URL:} - refresh-interval-seconds: ${MCP_PORTAL_REFRESH_INTERVAL_SECONDS:300} + refresh-ttl-seconds: ${MCP_PORTAL_REFRESH_TTL_SECONDS:300} + agent-routing-hints: + enabled: ${MCP_AGENT_ROUTING_HINTS_ENABLED:true} + manifest-path: ${MCP_AGENT_ROUTING_HINTS_MANIFEST_PATH:/tool-service-manifest} # Declared per deployment. baseEndpoint is the execution address and is owned by this file only: # nothing a Tool Service returns can change where MCP sends the call. bundles: [] 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 fefcb8c..a9a9409 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 @@ -2,10 +2,16 @@ package io.shinhanlife.dat.biz.mcp.method; import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import io.modelcontextprotocol.spec.McpSchema; +import io.shinhanlife.dat.biz.mcp.config.AgentRoutingHintsProperties; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcRequest; +import io.shinhanlife.dat.biz.mcp.registry.ToolRegistryClient; +import java.util.List; import org.junit.jupiter.api.Test; class InitializeHandlerTest { @@ -32,7 +38,7 @@ class InitializeHandlerTest { """ { "protocolVersion":"2025-11-25", - "capabilities":{"tools":{"listChanged":true}}, + "capabilities":{"tools":{"listChanged":false}}, "serverInfo":{ "name":"shl-axhub-mcp-server-external", "title":"SHL AX HUB MCP Server (EXTERNAL)", @@ -62,4 +68,52 @@ class InitializeHandlerTest { assertThat(serialized.path("serverInfo").path("title").asText()) .isEqualTo("SHL AX HUB MCP Server (OTH)"); } + + @Test + void includesToolServersWhenAgentRoutingHintsAreEnabled() throws Exception { + ToolRegistryClient registryClient = mock(ToolRegistryClient.class); + var routingManifest = io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER.readTree( + """ + { + "bundleId": "was-sys", + "revision": "revision-1", + "routingFunctions": [ + { + "type": "function", + "function": { + "name": "route_to_dat-was-sys", + "description_serialization": "시스템 업무 서버로 요청을 라우팅합니다.", + "routing_contract": { + "schema_version": "3.0", + "server_id": "dat-was-sys", + "category_key": "sys" + }, + "parameters": { + "additionalProperties": false, + "type": "object", + "properties": {} + } + } + } + ] + } + """); + when(registryClient.fetchRoutingManifests("external", "/tool-service-manifest")) + .thenReturn(List.of(routingManifest)); + InitializeHandler handler = new InitializeHandler( + properties(false, false), + new AgentRoutingHintsProperties(true, "tool-service-manifest"), + registryClient); + JsonRpcRequest request = new JsonRpcRequest( + "initialize", JsonNodeFactory.instance.objectNode(), JsonNodeFactory.instance.numberNode(3)); + + var response = handler.handle(request, io.shinhanlife.dat.biz.mcp.TestFixtures.context()); + var serialized = io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER.valueToTree(response.result()); + + assertThat(serialized.path("_meta").path("routeKey").asText()).isEqualTo("external"); + assertThat(serialized.path("_meta").path("toolServers")) + .singleElement() + .isEqualTo(routingManifest); + verify(registryClient).fetchRoutingManifests("external", "/tool-service-manifest"); + } } 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 e621159..064f7bc 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 @@ -9,6 +9,7 @@ import static org.mockito.Mockito.never; 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.McpProperties; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -176,6 +177,32 @@ class PortalToolRegistryClientTest { assertThat(toolServer.getRequestCount()).isEqualTo(1); } + @Test + void fetchesRoutingManifestForTheRequestedPortalRoute() throws Exception { + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(jsonResponse(routingManifest("external-tool-server", "revision-1"))); + PortalToolRegistryClient client = client(); + + List manifests = client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(manifests) + .singleElement() + .isEqualTo(OBJECT_MAPPER.readTree(routingManifest("external-tool-server", "revision-1"))); + assertThat(toolServer.takeRequest().getPath()).isEqualTo("/tool-service-manifest"); + } + + @Test + void ignoresFailedRoutingManifestWithoutFailingInitializeHintCollection() { + portal.enqueue(portalRegistry("portal-1")); + toolServer.enqueue(new MockResponse().setResponseCode(503)); + PortalToolRegistryClient client = client(); + + List manifests = client.fetchRoutingManifests("external", "/tool-service-manifest"); + + assertThat(manifests).isEmpty(); + assertThat(toolServer.getRequestCount()).isEqualTo(1); + } + @Test void rejectsBlankRouteInsteadOfUsingConfiguredDefaultRoute() { PortalToolRegistryClient client = client(); @@ -370,6 +397,35 @@ class PortalToolRegistryClientTest { return manifestForBundle("external-tool-server", revision, toolName); } + private String routingManifest(String bundleId, String revision) { + return """ + { + "bundleId": "%s", + "revision": "%s", + "routingFunctions": [ + { + "type": "function", + "function": { + "name": "route_to_%s", + "description_serialization": "테스트 routing manifest입니다.", + "routing_contract": { + "schema_version": "3.0", + "server_id": "%s", + "category_key": "external" + }, + "parameters": { + "additionalProperties": false, + "type": "object", + "properties": {} + } + } + } + ] + } + """ + .formatted(bundleId, revision, bundleId, bundleId); + } + private MockResponse manifestForBundle(String bundleId, String toolName) { return manifestForBundle(bundleId, "manifest-1", toolName); } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java index 7cd2254..cbb9d5d 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java @@ -1,35 +1,31 @@ package io.shinhanlife.dat.biz.mcp.registry; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Scheduled; class ToolRegistryRefreshSchedulerTest { @Test - void refreshesManifestImmediatelyWhenPortalRegistryChanges() { + void preloadsRegistryOnceWhenApplicationIsReady() { ToolRegistryService service = mock(ToolRegistryService.class); when(service.refreshSourceRegistry()).thenReturn(true); ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service); - scheduler.scheduledPortalRefresh(); + scheduler.preload(); + verify(service).warmStartFromSharedCache(); verify(service).refreshSourceRegistry(); verify(service).refreshKnownRoutes(); } @Test - void keepsManifestScheduleSeparateWhenPortalRegistryIsUnchanged() { - ToolRegistryService service = mock(ToolRegistryService.class); - when(service.refreshSourceRegistry()).thenReturn(false); - ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service); - - scheduler.scheduledPortalRefresh(); - - verify(service).refreshSourceRegistry(); - verify(service, never()).refreshKnownRoutes(); + void doesNotDeclareScheduledPollingMethods() { + assertThat(java.util.Arrays.stream(ToolRegistryRefreshScheduler.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 b2dcb38..6cd66a4 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 @@ -1,5 +1,6 @@ package io.shinhanlife.dat.biz.mcp.registry; +import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties; import static io.shinhanlife.dat.biz.mcp.TestFixtures.tool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -13,6 +14,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.shinhanlife.dat.biz.mcp.config.McpProperties; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import java.util.List; @@ -21,6 +24,7 @@ import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.context.ApplicationEventPublisher; @@ -204,6 +208,66 @@ class ToolRegistryServiceTest { .isEqualTo("http://sms-tool"); } + @Test + void keepsMemorySnapshotWhenManifestTtlHasNotExpired() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + AtomicLong now = new AtomicLong(0); + when(client.fetchTools("external")).thenReturn(List.of(tool("http://first-tool"))); + ToolRegistryService service = serviceWithClock(client, properties(false, false), now); + service.refresh("external"); + clearInvocations(client); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://first-tool"); + + verifyNoInteractions(client); + } + + @Test + void refreshesManifestWhenRequestArrivesAfterManifestTtl() { + ToolRegistryClient client = mock(ToolRegistryClient.class); + AtomicLong now = new AtomicLong(0); + when(client.fetchTools("external")) + .thenReturn(List.of(tool("http://first-tool"))) + .thenReturn(List.of(tool("http://second-tool"))); + ToolRegistryService service = serviceWithClock(client, properties(false, false), now); + service.refresh("external"); + + now.set(31_000); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://second-tool"); + verify(client, times(2)).fetchTools("external"); + } + + @Test + void refreshesManifestImmediatelyWhenPortalRegistryChangesAtRequestTime() { + 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://first-tool"))) + .thenReturn(List.of(tool("http://second-tool"))); + when(client.refreshSourceRegistry()).thenReturn(false).thenReturn(true); + ToolRegistryService service = serviceWithClock(client, mcpProperties, now); + service.refresh("external"); + service.refreshSourceRegistry(); + + now.set(2_000); + service.refreshSourceRegistryIfStale(); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://second-tool"); + verify(client, times(2)).fetchTools("external"); + verify(client, times(2)).refreshSourceRegistry(); + } + @Test void refreshesAllPortalRoutesFromOneAggregateRegistrySnapshot() { ToolRegistryClient client = mock(ToolRegistryClient.class); @@ -249,4 +313,31 @@ class ToolRegistryServiceTest { assertThat(eventCaptor.getValue().notification().method()) .isEqualTo("notifications/tools/list_changed"); } + + private ToolRegistryService serviceWithClock( + ToolRegistryClient client, McpProperties properties, AtomicLong now) { + return new ToolRegistryService( + client, + Optional.empty(), + event -> { }, + new ObjectMapper(), + properties, + now::get); + } + + private McpProperties propertiesWithTtl(boolean portalEnabled, long manifestTtlSeconds, long portalTtlSeconds) { + McpProperties base = properties(false, false); + return new McpProperties( + base.identity(), + base.endpointPath(), + base.server(), + new McpProperties.Registry(base.registry().localToolFile(), manifestTtlSeconds, 0), + base.toolClient(), + base.redis(), + base.trace(), + base.protocol(), + base.discovery(), + new McpProperties.Portal(portalEnabled, "", "file:./registry.json", portalTtlSeconds), + base.bundles()); + } }