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