tool-service-manifest 적용
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m32s

This commit is contained in:
2026-09-02 16:24:56 +09:00
parent 548c36f97d
commit 9905d52db3
20 changed files with 651 additions and 94 deletions

View File

@@ -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 매니페스트만 원천으로 쓴다.

View File

@@ -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을 내린다.

View File

@@ -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에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다.

View File

@@ -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 {
/**

View File

@@ -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
*
* <pre>
* ============ 개정이력 ============
* 수정일 수정자 수정내용
* ---------- ---------- ----------------
* 2026.09.02 j.h.w 최초생성
*
* </pre>
*/
@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;
}
}
}

View File

@@ -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) {
}
/**

View File

@@ -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<String, Object> 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<String, Object> routingHintMeta(String routeKey) {
if (!routingHintsProperties.enabled() || routeKey == null || registryClient == null) {
return Map.of();
}
List<JsonNode> routingManifests =
registryClient.fetchRoutingManifests(routeKey, routingHintsProperties.manifestPath());
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("routeKey", routeKey);
meta.put("toolServers", routingManifests);
return meta;
}
}

View File

@@ -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<JsonNode> fetchRoutingManifests(String routeKey, String manifestPath) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
ensurePortalRegistryLoaded();
List<Bundle> bundles = bundlesByRoute.get(normalizedRouteKey);
if (bundles == null || bundles.isEmpty()) {
return List.of();
}
List<JsonNode> 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<JsonNode> 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 호출 주기를 분리합니다.
*/

View File

@@ -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));
}
/**

View File

@@ -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<JsonNode> fetchRoutingManifests(String routeKey, String manifestPath) {
return List.of();
}
/**
* route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다.
*

View File

@@ -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,

View File

@@ -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<RedisToolRegistryCache> redisCache;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
private final McpProperties properties;
private final LongSupplier currentTimeMillis;
private final ConcurrentMap<String, List<ToolMetadata>> snapshotsByRoute = new ConcurrentHashMap<>();
private final ConcurrentMap<String, CompletableFuture<List<ToolMetadata>>> refreshInFlightByRoute =
new ConcurrentHashMap<>();
private final ConcurrentMap<String, Long> 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<RedisToolRegistryCache> 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<RedisToolRegistryCache> 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<RedisToolRegistryCache> 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<RedisToolRegistryCache> 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<ToolMetadata> listTools(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
refreshIfStale(normalizedRouteKey);
List<ToolMetadata> 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<ToolMetadata> 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<String, List<ToolMetadata>> 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<String> 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());
}
}

View File

@@ -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가 임의 값을 생성하지 않습니다.
*

View File

@@ -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 조건 충족 여부를 반환합니다.

View File

@@ -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

View File

@@ -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: []

View File

@@ -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");
}
}

View File

@@ -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<JsonNode> 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<JsonNode> 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);
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}