tool-service-manifest 적용
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m32s
All checks were successful
Deploy Gateway / deploy (push) Successful in 2m32s
This commit is contained in:
@@ -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 매니페스트만 원천으로 쓴다.
|
||||
|
||||
@@ -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을 내린다.
|
||||
|
||||
@@ -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에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 호출 주기를 분리합니다.
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 목록을 읽습니다.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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가 임의 값을 생성하지 않습니다.
|
||||
*
|
||||
|
||||
@@ -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 조건 충족 여부를 반환합니다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user