diff --git a/deploy/portal-registry.json b/deploy/portal-registry.json index be224c7..3a80bcc 100644 --- a/deploy/portal-registry.json +++ b/deploy/portal-registry.json @@ -7,8 +7,7 @@ "status": "ACTIVE", "serviceKey": "was-cus", "serviceDomain": "https://tool-cus.devjun.net", - "manifestPath": "/tool-manifest", - "executeBasePath": "/mcp" + "manifestPath": "/tool-manifest" } ] }, @@ -19,8 +18,7 @@ "status": "ACTIVE", "serviceKey": "was-sal", "serviceDomain": "https://tool-sal.devjun.net", - "manifestPath": "/tool-manifest", - "executeBasePath": "/mcp" + "manifestPath": "/tool-manifest" } ] }, @@ -31,8 +29,7 @@ "status": "ACTIVE", "serviceKey": "was-pro", "serviceDomain": "https://tool-pro.devjun.net", - "manifestPath": "/tool-manifest", - "executeBasePath": "/mcp" + "manifestPath": "/tool-manifest" } ] }, @@ -43,8 +40,7 @@ "status": "ACTIVE", "serviceKey": "was-sys", "serviceDomain": "https://tool-sys.devjun.net", - "manifestPath": "/tool-manifest", - "executeBasePath": "/mcp" + "manifestPath": "/tool-manifest" } ] } diff --git a/docs/architecture.md b/docs/architecture.md index 5894be7..b9c1c22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T 7. `tools/list`는 `ToolRegistryService`의 in-memory snapshot에서 실행 metadata를 얻는다. 요청 경로는 Redis를 호출하지 않으므로 Redis 장애·지연이 응답에 영향을 주지 않으며, snapshot이 비어 있는 기동 직후에만 Tool catalog provider를 한 번 조회한다. 이후 `ToolsListHandler`가 MCP SDK의 `Tool`과 `ListToolsResult`로 변환한다. local 기본 구성은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 bundle별 local manifest sample을 cold-start fallback으로 사용한다. 운영은 이 배포가 보는 Tool Service 매니페스트의 사용 가능한 성공본만 원천으로 사용한다. 8. `tools/call`은 `ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다. 9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다. -10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. manifest endpoint가 절대 HTTP(S) URL이면 Tool Server가 제공한 실행 주소 원천으로 허용하고, 상대 경로이면 Portal registry의 `serviceDomain` 뒤에 붙인다. +10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. 상대 endpoint는 Portal registry의 `serviceDomain` 뒤에 붙이며, 절대 HTTP(S) endpoint도 `serviceDomain`과 scheme·host·유효 port가 같은 origin일 때만 허용한다. 이 경계를 벗어난 endpoint는 bundle 갱신 실패로 격리해 API Key와 사용자 헤더가 Portal이 승인하지 않은 서버로 전달되지 않게 한다. 11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. `X-Caller-IP`와 `X-Caller-Host`는 기동 시 Downward API의 `POD_IP`·`POD_NAME`을 우선 사용하고, 값이 없을 때만 로컬 host를 한 번 조회해 프로세스 수명 동안 재사용한다. Portal Registry와 Tool manifest 조회도 별도의 공유 JDK HTTP client를 사용한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다. 12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent`의 `result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다. 13. local과 운영 모두 같은 `name` lookup, endpoint/timeout, inputSchema validation 경로를 사용한다. diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json index 9d8d772..3bd9fc9 100644 --- a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-execution-error-response.json @@ -5,7 +5,7 @@ "content": [ { "type": "text", - "text": "customer.search@1.0.0: timed out" + "text": "{\"reasonCode\":\"TOOL_TIMEOUT\",\"message\":\"Tool execution timed out.\"}" } ], "isError": true diff --git a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json index 5cea45a..e626b8f 100644 --- a/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json +++ b/docs/contracts/agent-builder-mcp/examples/agentbuilder-v0.3/tools-call-invalid-params-response.json @@ -3,6 +3,10 @@ "id": 3, "error": { "code": -32602, - "message": "Invalid params: 'query' is required" + "message": "Invalid params", + "data": { + "reasonCode": "INVALID_PARAMS", + "message": "Request parameters are invalid." + } } } diff --git a/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md b/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md index 2011810..e3d0ccf 100644 --- a/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md +++ b/docs/contracts/agent-builder-mcp/protocol-v0.3-streaming-policy.md @@ -58,7 +58,7 @@ ## tools/call 성공·오류 응답 기준 -Agent Builder는 HTTP 상태만으로 성공 여부를 판단하지 않고 JSON-RPC body의 최상위 `result` 또는 `error`를 확인해야 한다. 일반적인 JSON-RPC 요청 오류는 HTTP `200 OK`와 함께 최상위 `error`로 반환될 수 있다. `-32602`의 `error.message`는 `Invalid params: <상세 원인>` 형식이며, 예를 들어 필수 `query`가 없으면 `Invalid params: 'query' is required`를 반환한다. 선택적인 `error.data`에는 `guid`와 상세 원인을 추가로 담을 수 있다. 단, `MCP-Protocol-Version` 누락·미지원처럼 HTTP transport 단계에서 거부된 요청은 HTTP `400 Bad Request`다. +Agent Builder는 HTTP 상태만으로 성공 여부를 판단하지 않고 JSON-RPC body의 최상위 `result` 또는 `error`를 확인해야 한다. 일반적인 JSON-RPC 요청 오류는 HTTP `200 OK`와 함께 최상위 `error`로 반환될 수 있다. `-32602`의 `error.message`는 내부 검증 내용을 노출하지 않는 `Invalid params`이며, `error.data.reasonCode`와 `error.data.message`에는 Agent Builder가 안전하게 처리할 수 있는 정형화된 공개 오류 정보가 담긴다. 단, `MCP-Protocol-Version` 누락·미지원처럼 HTTP transport 단계에서 거부된 요청은 HTTP `400 Bad Request`다. | 상황 | HTTP 상태 | JSON-RPC body | `isError` | 현재 구현의 처리 주체 | |---|---:|---|---|---| diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java index 9f0350a..fd67143 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClient.java @@ -7,8 +7,10 @@ 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.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.core.io.Resource; @@ -29,6 +31,7 @@ import org.springframework.stereotype.Component; * 수정일 수정자 수정내용 * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 + * 2026.09.18 j.h.w 요청 경로의 파일 I/O 제거와 원천 지문 일관성 보강 * * */ @@ -40,6 +43,8 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { private final ResourceLoader resourceLoader; private final ObjectMapper objectMapper; private final McpProperties properties; + private final String location; + private final AtomicReference currentFingerprint; /** * local Tool catalog의 resource loader, JSON mapper, 파일 위치 설정을 주입받습니다. @@ -53,28 +58,70 @@ public class LocalFileToolRegistryClient implements ToolRegistryClient { this.resourceLoader = resourceLoader; this.objectMapper = objectMapper; this.properties = properties; + this.location = properties.registry().localToolFile(); + this.currentFingerprint = new AtomicReference<>( + SourceFingerprint.of(List.of(location, "not-loaded"))); } /** - * local profile에서 설정된 JSON 파일의 {@code result.tools[]}를 읽어 실행 metadata 목록으로 변환합니다. 파일이 없거나 읽을 수 없거나 내용이 비어 있으면 Registry unavailable 오류로 변환합니다. + * 마지막 local catalog 조회에서 확정한 원천 지문을 memory에서 반환합니다. 요청 경로와 readiness에서 호출되어도 파일을 다시 열지 않습니다. * * @param routeKey 처리 대상 route key입니다. - * @return 조회 또는 변환된 목록 정보를 반환합니다. + * @return 현재 원천 구성의 지문을 반환합니다. */ @Override - public List fetchTools(String routeKey) { - String location = properties.registry().localToolFile(); + public SourceFingerprint currentSourceFingerprint(String routeKey) { + return currentFingerprint.get(); + } + + /** + * local profile에서 설정된 JSON 파일을 한 번 읽어 같은 bytes로 원천 지문과 실행 metadata를 함께 만듭니다. 읽은 즉시 현재 지문을 교체하므로 파일 내용이 잘못된 경우에도 이전 snapshot이 같은 세대로 남지 않습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 결과와 그 결과를 만든 파일 원천 지문을 반환합니다. + */ + @Override + public ToolSourceSnapshot fetchToolSnapshot(String routeKey) { Resource resource = resourceLoader.getResource(location); try (var inputStream = resource.getInputStream()) { - JsonNode document = objectMapper.readTree(inputStream); - return toToolMetadataList(document, location); + byte[] content = inputStream.readAllBytes(); + SourceFingerprint fingerprint = fingerprint(content); + currentFingerprint.set(fingerprint); + JsonNode document = objectMapper.readTree(content); + return new ToolSourceSnapshot( + routeKey == null ? "" : routeKey, + fingerprint, + toToolMetadataList(document, location)); } catch (JsonRpcException exception) { throw exception; } catch (IOException exception) { + currentFingerprint.set(SourceFingerprint.of(List.of(location, "unreadable"))); throw unavailable(location, "Unable to read local Tool catalog", exception); } } + /** + * 기존 Tool 목록 조회 계약을 유지하면서 지문과 목록을 함께 만드는 단일 파일 조회 경로를 사용합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return local catalog의 실행 metadata 목록을 반환합니다. + */ + @Override + public List fetchTools(String routeKey) { + return fetchToolSnapshot(routeKey).tools(); + } + + /** + * local catalog의 위치와 실제 bytes를 사용해 현재 파일 세대를 식별하는 지문을 만듭니다. 문자열 변환은 지문 입력에만 사용하고 Tool 파싱은 원본 bytes로 수행합니다. + * + * @param content local catalog 파일 bytes입니다. + * @return 파일 위치와 내용을 반영한 원천 지문을 반환합니다. + */ + private SourceFingerprint fingerprint(byte[] content) { + return SourceFingerprint.of( + List.of(location, new String(content, StandardCharsets.UTF_8))); + } + /** * Agent Builder tools/list 응답 또는 Tool Service manifest의 공개 정의와 {@code _meta} 실행 정보를 내부 ToolMetadata로 조합합니다. 두 형식 모두 배열이 없으면 오류로 처리해 빈 목록을 조용히 반환하지 않습니다. * 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 17b9400..b75a7ae 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 @@ -171,6 +171,77 @@ public class PortalToolRegistryClient implements ToolRegistryClient { * * @return 조회 또는 변환된 목록 정보를 반환합니다. */ + /** + * 현재 memory에 적재된 route bundle 구성만으로 원천 지문을 계산합니다. Portal API나 Tool Service를 호출하지 않습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 현재 원천 구성의 지문을 반환합니다. + */ + @Override + public SourceFingerprint currentSourceFingerprint(String routeKey) { + List bundles = bundlesByRoute.get(normalizeRouteKey(routeKey)); + return fingerprintOf(routeKey, bundles); + } + + /** + * route의 bundle 구성을 한 번 확정한 뒤 그 구성으로 manifest를 조회하고, 조회 결과와 그 구성의 지문을 함께 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 결과와 그 원천 지문을 반환합니다. + */ + @Override + public ToolSourceSnapshot fetchToolSnapshot(String routeKey) { + String normalizedRouteKey = normalizeRouteKey(routeKey); + ensurePortalRegistryLoaded(); + // bundle 목록을 한 번만 꺼내 지문 계산과 manifest 조회에 함께 사용한다. + // 두 번 읽으면 그 사이 Portal 구성이 바뀌어 옛 조회 결과가 새 지문으로 저장될 수 있다. + List bundles = bundlesByRoute.get(normalizedRouteKey); + if (bundles == null) { + throw unavailable("Portal registry route is not found: " + normalizedRouteKey); + } + return new ToolSourceSnapshot( + normalizedRouteKey, + fingerprintOf(normalizedRouteKey, bundles), + fetchRouteTools(normalizedRouteKey, bundles)); + } + + /** + * Portal이 알고 있는 모든 route를 route별로 한 번씩 조회해 결과와 원천 지문을 함께 반환합니다. 일부 route 조회가 실패하면 그 route만 결과에서 제외합니다. + * + * @return route key별 조회 결과와 원천 지문을 반환합니다. + */ + @Override + public Map fetchAllToolSnapshots() { + ensurePortalRegistryLoaded(); + Map snapshots = new LinkedHashMap<>(); + bundlesByRoute.forEach( + (routeKey, bundles) -> + fetchRouteToolsSafely(routeKey, bundles) + .ifPresent( + tools -> + snapshots.put( + routeKey, + new ToolSourceSnapshot( + routeKey, + fingerprintOf(routeKey, bundles), + tools)))); + return Map.copyOf(snapshots); + } + + /** + * route의 현재 bundle 구성으로 원천 지문을 만듭니다. 아직 endpoint 구성을 적재하지 못한 route는 적재 완료 후의 지문과 반드시 달라지도록 별도의 값으로 계산합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param bundles 지문 대상 bundle 목록이며 아직 적재되지 않았으면 {@code null}입니다. + * @return 계산된 원천 지문을 반환합니다. + */ + private SourceFingerprint fingerprintOf(String routeKey, List bundles) { + if (bundles == null) { + return SourceFingerprint.of(List.of("portal-route-not-loaded", routeKey == null ? "" : routeKey)); + } + return ToolBundleDiscovery.fingerprintOf(bundles); + } + @Override public Map> fetchAllTools() { ensurePortalRegistryLoaded(); 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 a965047..590c517 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 @@ -35,6 +35,10 @@ import org.springframework.stereotype.Component; @ConditionalOnProperty(prefix = "mcp.redis", name = "enabled", havingValue = "true") public class RedisToolRegistryCache { + // 주의: 이 cache의 key는 route만 구분하고 Tool Service 주소는 구분하지 않는다. + // 주소가 바뀐 뒤 새 manifest 조회가 실패하면 옛 endpoint가 담긴 snapshot이 복원될 수 있으므로, + // Redis를 활성화하기 전에 route source fingerprint 기반 cache 격리를 먼저 구현해야 한다. + static final String CACHE_SCHEMA_VERSION = "v2"; private static final Logger logger = LoggerFactory.getLogger(RedisToolRegistryCache.class); diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/SourceFingerprint.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/SourceFingerprint.java new file mode 100644 index 0000000..9f4d9c8 --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/SourceFingerprint.java @@ -0,0 +1,56 @@ +package io.shinhanlife.dat.biz.mcp.registry; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; + +/** + * @package io.shinhanlife.dat.biz.mcp.registry + * @className SourceFingerprint + * @description Tool 목록을 만들어 낸 원천 구성을 식별하는 값입니다. 값이 같으면 같은 원천 구성에서 나온 목록이고, 값이 달라지면 원천 세대가 바뀐 것으로 판단합니다. + * @author j.h.w + * @create 2026.09.18 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.18   j.h.w         최초생성
+ *
+ * 
+ */ +public record SourceFingerprint(String value) { + + private static final char SEPARATOR = (char) 31; + + /** + * 빈 지문을 허용하지 않습니다. 원천을 식별할 수 없는 상태를 빈 문자열로 표현하면 서로 다른 세대가 같은 것으로 판정되기 때문입니다. + */ + public SourceFingerprint { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("source fingerprint is required"); + } + } + + /** + * 원천을 식별하는 문자열 목록을 주어진 순서대로 이어 붙여 SHA-256 지문을 만듭니다. 원본 문자열은 보관하지 않으며 비교에는 해시 결과만 사용합니다. + * + * @param parts 지문 대상 원천 식별 문자열 목록입니다. + * @return 계산된 지문을 반환합니다. + */ + public static SourceFingerprint of(List parts) { + StringBuilder joined = new StringBuilder(); + for (String part : parts) { + joined.append(part == null ? "" : part).append(SEPARATOR); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(joined.toString().getBytes(StandardCharsets.UTF_8)); + return new SourceFingerprint(HexFormat.of().formatHex(hash)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java index 61da1d5..2942821 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscovery.java @@ -43,6 +43,7 @@ import org.springframework.web.client.RestClient; * ---------- ---------- ---------------- * 2026.08.06 j.h.w 최초생성 * 2026.09.08 j.h.w local·dev resource manifest 직접 조회 지원 + * 2026.09.18 j.h.w Tool 절대 endpoint를 Portal serviceDomain과 동일 origin으로 제한 * * */ @@ -135,7 +136,15 @@ public class ToolBundleDiscovery { * @return 처리 결과를 반환합니다. */ BundleResult discoverOne(Bundle bundle) { - BundleState state = states.computeIfAbsent(bundle.id(), id -> new BundleState()); + BundleSource source = BundleSource.of(bundle); + // 같은 serviceKey라도 원천 주소가 바뀌면 이전 상태를 물려받지 않는다. + // 옛 last-good에는 옛 endpoint가 들어 있어, 조회 실패 시 옛 서버로 Tool을 호출하게 된다. + BundleState state = states.compute( + bundle.id(), + (id, existing) -> + existing == null || !existing.matchesSource(source) + ? new BundleState(source) + : existing); try { Manifest manifest = fetchAndValidate(bundle); return state.recordSuccess(bundle.id(), manifest.tools(), manifest.revision()); @@ -309,7 +318,7 @@ public class ToolBundleDiscovery { } /** - * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, Tool 실행 경로는 Tool Service manifest의 {@code endpoint} 또는 {@code _meta.endpoint}에서 가져옵니다. 포털 registry가 제공한 {@code serviceDomain}은 상대 endpoint를 절대 URL로 바꾸는 기준으로만 사용합니다. 이름 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. + * 매니페스트의 Tool 하나를 실행 metadata로 변환하며, Tool 실행 경로는 Tool Service manifest의 {@code endpoint} 또는 {@code _meta.endpoint}에서 가져옵니다. 포털 registry가 제공한 {@code serviceDomain}은 상대 endpoint를 절대 URL로 바꾸고 절대 endpoint의 origin을 제한하는 신뢰 기준입니다. 이름 규칙·{@code namePrefix}·필수 필드를 위반하면 bundle 전체를 거부하도록 예외를 던집니다. * * @param bundle 입력값입니다. * @param tool Tool 처리 정보입니다. @@ -351,7 +360,7 @@ public class ToolBundleDiscovery { } /** - * Tool Service manifest가 제공한 Tool 실행 endpoint를 실제 호출 URL로 확정합니다. 최상위 {@code endpoint}를 먼저 읽고 없으면 {@code _meta.endpoint}를 사용합니다. 상대 경로는 포털 registry의 {@code serviceDomain} 뒤에 붙이고, 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용합니다. endpoint가 비어 있거나 HTTP(S)가 아니면 bundle 전체를 거부합니다. + * Tool Service manifest가 제공한 Tool 실행 endpoint를 실제 호출 URL로 확정합니다. 최상위 {@code endpoint}를 먼저 읽고 없으면 {@code _meta.endpoint}를 사용합니다. 상대 경로는 포털 registry의 {@code serviceDomain} 뒤에 붙이며, 절대 URL은 serviceDomain과 scheme·host·port가 모두 같은 경우에만 허용합니다. endpoint가 비어 있거나 HTTP(S)가 아니거나 다른 origin을 가리키면 bundle 전체를 거부해 API Key와 사용자 헤더가 임의 host로 전달되지 않게 합니다. * * @param bundle 입력값입니다. * @param tool Tool 처리 정보입니다. @@ -377,9 +386,38 @@ public class ToolBundleDiscovery { } URI endpoint = endpointUri(trimmed, "Tool endpoint", toolName); URI resolved = endpoint.isAbsolute() ? endpoint : base.resolve("/" + trimmed.replaceAll("^/+", "")); + if (!hasSameOrigin(base, resolved)) { + throw new IllegalStateException("Tool endpoint must use the Portal serviceDomain origin"); + } return resolved.toString(); } + /** + * 두 HTTP(S) URI의 scheme, host, 유효 port가 같은지 확인합니다. 기본 port가 생략된 URI도 명시된 80 또는 443과 같은 origin으로 처리합니다. + * + * @param base Portal이 제공한 신뢰 기준 serviceDomain입니다. + * @param candidate Tool manifest가 선언해 실제 호출에 사용할 endpoint입니다. + * @return 두 URI가 같은 origin이면 {@code true}를 반환합니다. + */ + private boolean hasSameOrigin(URI base, URI candidate) { + return base.getScheme().equalsIgnoreCase(candidate.getScheme()) + && base.getHost().equalsIgnoreCase(candidate.getHost()) + && effectivePort(base) == effectivePort(candidate); + } + + /** + * URI에 port가 명시되지 않았으면 HTTP와 HTTPS의 표준 port로 보정해 origin 비교에 사용합니다. + * + * @param uri HTTP(S) URI입니다. + * @return 명시된 port 또는 scheme의 표준 port입니다. + */ + private int effectivePort(URI uri) { + if (uri.getPort() >= 0) { + return uri.getPort(); + } + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + /** * endpoint 문자열을 URI로 해석하고, Tool 실행에 사용할 수 있는 HTTP(S) 주소 또는 상대 경로인지 확인합니다. 잘못된 URI는 discovery 실패로 격리되어 직전 정상 snapshot을 유지하게 됩니다. * @@ -455,6 +493,71 @@ public class ToolBundleDiscovery { /** * 한 bundle의 조회 결과이며, 노출할 Tool 목록과 사용 가능한 snapshot 여부를 함께 전달합니다. */ + /** + * bundle 하나의 실행 원천을 식별하는 값입니다. serviceKey가 같아도 이 값 중 하나가 달라지면 다른 원천으로 취급해 이전 조회 상태를 재사용하지 않습니다. + * + * @param serviceKey Portal이 부여한 Tool Service 식별자입니다. + * @param manifestUrl manifest 조회 주소입니다. + * @param baseEndpoint Tool 실행 기준 주소입니다. + * @param namePrefix 이 bundle이 허용하는 Tool 이름 접두사입니다. + */ + public record BundleSource( + String serviceKey, + String manifestUrl, + String baseEndpoint, + String namePrefix) { + + /** + * bundle 설정에서 원천 식별값만 뽑아냅니다. + * + * @param bundle 대상 bundle 설정입니다. + * @return bundle 원천 식별값을 반환합니다. + */ + public static BundleSource of(Bundle bundle) { + return new BundleSource( + bundle.id(), bundle.manifestUrl(), bundle.baseEndpoint(), bundle.namePrefix()); + } + + /** + * 지문 계산에 사용할 정규화된 문자열을 만듭니다. + * + * @return 원천 식별 문자열을 반환합니다. + */ + public String toFingerprintPart() { + return String.join( + "|", + nullToEmpty(serviceKey), + nullToEmpty(manifestUrl), + nullToEmpty(baseEndpoint), + nullToEmpty(namePrefix)); + } + + /** + * null을 빈 문자열로 바꿔 지문 계산이 실패하지 않게 합니다. + * + * @param value 변환할 값입니다. + * @return 변환된 문자열을 반환합니다. + */ + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + } + + /** + * bundle 목록 전체를 원천 지문 하나로 요약합니다. 목록 순서에 영향을 받지 않도록 serviceKey 기준으로 정렬한 뒤 계산합니다. + * + * @param bundles 지문 대상 bundle 목록입니다. + * @return 계산된 원천 지문을 반환합니다. + */ + public static SourceFingerprint fingerprintOf(List bundles) { + List parts = bundles.stream() + .map(BundleSource::of) + .map(BundleSource::toFingerprintPart) + .sorted() + .toList(); + return SourceFingerprint.of(parts); + } + public record BundleResult(String bundleId, boolean usableSnapshot, List tools) { public BundleResult { @@ -489,6 +592,7 @@ public class ToolBundleDiscovery { private static final class BundleState { + private final BundleSource source; private List lastGood; private String revision; private Instant lastSuccessAt; @@ -496,6 +600,25 @@ public class ToolBundleDiscovery { private String lastFailureReason; private boolean fallbackSnapshot; + /** + * 이 상태가 어느 원천 구성에서 만들어졌는지 함께 보관합니다. + * + * @param source 이 상태를 만든 bundle 원천 구성입니다. + */ + private BundleState(BundleSource source) { + this.source = source; + } + + /** + * 보관 중인 상태가 주어진 원천 구성에서 만들어진 것인지 확인합니다. + * + * @param candidate 비교할 bundle 원천 구성입니다. + * @return 같은 원천에서 만들어졌으면 {@code true}입니다. + */ + private boolean matchesSource(BundleSource candidate) { + return source.equals(candidate); + } + /** * 조회 성공 결과를 채택하고 실패 상태를 모두 초기화합니다. * diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java index cdcb0f5..eb9d719 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleRegistryClient.java @@ -54,6 +54,17 @@ public class ToolBundleRegistryClient implements ToolRegistryClient { * @param routeKey 처리 대상 route key입니다. * @return 조회 또는 변환된 목록 정보를 반환합니다. */ + /** + * 설정에 선언된 활성 bundle 목록으로 원천 지문을 계산합니다. 설정에서 bundle 주소를 바꾸면 지문도 바뀌어 옛 snapshot이 재사용되지 않습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 현재 원천 구성의 지문을 반환합니다. + */ + @Override + public SourceFingerprint currentSourceFingerprint(String routeKey) { + return ToolBundleDiscovery.fingerprintOf(properties.enabledBundles()); + } + @Override public List fetchTools(String routeKey) { List results = discovery.discoverAll(); 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 5217275..2dfddcd 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 @@ -42,6 +42,48 @@ public interface ToolRegistryClient { return Map.of(); } + /** + * 현재 memory에 적재된 원천 구성만으로 route의 원천 지문을 계산합니다. 원격 호출을 하지 않으며, 호출자는 이 값으로 memory snapshot이 아직 같은 원천 세대인지 확인합니다. + * 기본 구현은 원천 구성이 실행 중 바뀌지 않는 구현을 위해 구현체와 route에만 의존하는 고정 지문을 반환합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 현재 원천 구성의 지문을 반환합니다. + */ + default SourceFingerprint currentSourceFingerprint(String routeKey) { + return SourceFingerprint.of(List.of(getClass().getName(), routeKey == null ? "" : routeKey)); + } + + /** + * Tool 목록을 조회하고, 그 조회에 실제로 사용한 원천 구성의 지문을 함께 반환합니다. 지문을 따로 조회하면 조회와 지문 계산 사이에 원천이 바뀌어 옛 목록이 새 지문으로 저장될 수 있으므로 하나의 결과로 묶습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @return 조회 결과와 그 원천 지문을 반환합니다. + */ + default ToolSourceSnapshot fetchToolSnapshot(String routeKey) { + SourceFingerprint fingerprint = currentSourceFingerprint(routeKey); + return new ToolSourceSnapshot(routeKey, fingerprint, fetchTools(routeKey)); + } + + /** + * 원천이 route별 전체 Tool catalog를 한 번에 제공할 수 있으면 route별 조회 결과를 원천 지문과 함께 반환합니다. 지원하지 않는 구현은 빈 Map을 반환합니다. + * + * @return route key별 조회 결과와 원천 지문을 반환합니다. + */ + default Map fetchAllToolSnapshots() { + Map> tools = fetchAllTools(); + if (tools.isEmpty()) { + return Map.of(); + } + Map snapshots = new java.util.LinkedHashMap<>(); + tools.forEach( + (routeKey, routeTools) -> + snapshots.put( + routeKey, + new ToolSourceSnapshot( + routeKey, currentSourceFingerprint(routeKey), routeTools))); + return Map.copyOf(snapshots); + } + /** * Tool metadata 조회에 앞서 외부 registry의 endpoint 목록을 갱신합니다. 포털을 사용하지 않는 구현은 아무 작업도 하지 않으며, 호출자는 실패 시 기존 snapshot을 유지합니다. * 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 2a504fa..0854e89 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 @@ -52,6 +52,9 @@ public class ToolRegistryService implements McpRouteKeyValidator { // Portal registry를 한 번도 적재하지 못한 동안에는 TTL 대신 이 간격으로 다시 조회한다. // 최초 적재 전에는 last-good snapshot이 없어 모든 route 요청이 실패하므로 TTL(기본 300초)만큼 기다리지 않는다. private static final long PORTAL_INITIAL_LOAD_RETRY_MILLIS = 10_000L; + // 원천 세대가 바뀌어 조회 결과를 버리는 것은 주소 변경 직후 한 번은 정상이다. + // 반복되면 설정이 흔들린다는 신호이므로 cooldown을 두고 WARN으로 올린다. + private static final long STALE_DISCARD_WARN_COOLDOWN_MILLIS = 60_000L; private final ToolRegistryClient registryClient; private final Optional redisCache; @@ -59,7 +62,8 @@ public class ToolRegistryService implements McpRouteKeyValidator { private final ObjectMapper objectMapper; private final McpProperties properties; private final LongSupplier currentTimeMillis; - private final ConcurrentMap> snapshotsByRoute = new ConcurrentHashMap<>(); + private final ConcurrentMap snapshotsByRoute = new ConcurrentHashMap<>(); + private final ConcurrentMap staleDiscardLogAtByRoute = new ConcurrentHashMap<>(); private final ConcurrentMap>> refreshInFlightByRoute = new ConcurrentHashMap<>(); private final ConcurrentMap manifestRefreshAttemptsByRoute = new ConcurrentHashMap<>(); @@ -156,20 +160,61 @@ 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; + ToolSourceSnapshot memory = snapshotsByRoute.get(normalizedRouteKey); + if (memory != null && matchesCurrentSource(normalizedRouteKey, memory)) { + return memory.tools(); } - return refresh(normalizedRouteKey); + if (memory != null) { + // 사용 직전 마지막 확인이다. 저장 검사 이후 늦게 완료된 옛 세대 결과나 + // 저장 후 바뀐 원천 구성은 여기서만 걸러진다. + discardStaleSnapshot(normalizedRouteKey, memory); + } + List refreshed = refresh(normalizedRouteKey); + ToolSourceSnapshot stored = snapshotsByRoute.get(normalizedRouteKey); + if (stored == null || !matchesCurrentSource(normalizedRouteKey, stored)) { + // 재조회는 한 번만 한다. 원천이 계속 바뀌는 동안 요청이 무한히 재조회하지 않게 하고, + // 목적지가 확정되지 않은 상태에서는 Tool을 호출하지 않는다. + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, + "Tool source changed while refreshing route: " + normalizedRouteKey); + } + return refreshed; } /** - * 요청 처리에 사용할 수 있는 route별 Tool snapshot이 memory에 하나라도 있는지 반환합니다. readiness 확인 과정에서 원격 Tool Service나 Redis를 직접 호출하지 않기 위한 상태 판단용 메서드입니다. + * memory snapshot이 현재 원천 구성과 같은 세대인지 확인합니다. 원격 호출 없이 Registry client가 memory로 계산한 지문만 비교합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param snapshot 확인할 memory snapshot입니다. + * @return 같은 세대이면 {@code true}입니다. + */ + private boolean matchesCurrentSource(String routeKey, ToolSourceSnapshot snapshot) { + return snapshot.sourceFingerprint().equals(registryClient.currentSourceFingerprint(routeKey)); + } + + /** + * 원천 세대가 달라진 memory snapshot을 제거하고 폐기 사실을 기록합니다. 같은 route에서 반복 폐기되면 cooldown 안에서 한 번만 WARN으로 올립니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param snapshot 폐기할 memory snapshot입니다. + */ + private void discardStaleSnapshot(String routeKey, ToolSourceSnapshot snapshot) { + boolean removed = snapshotsByRoute.remove(routeKey, snapshot); + if (!removed) { + return; + } + manifestRefreshAttemptsByRoute.remove(routeKey); + logStaleDiscard(routeKey); + } + + /** + * 현재 원천 구성과 같은 세대인 route별 Tool snapshot이 memory에 하나라도 있는지 반환합니다. readiness 확인 과정에서는 원격 Tool Service나 Redis를 호출하지 않고 Registry client의 memory 지문만 비교합니다. * * @return 조건 충족 여부를 반환합니다. */ public boolean hasUsableSnapshot() { - return !snapshotsByRoute.isEmpty(); + return snapshotsByRoute.entrySet().stream() + .anyMatch(entry -> matchesCurrentSource(entry.getKey(), entry.getValue())); } /** @@ -182,7 +227,12 @@ public class ToolRegistryService implements McpRouteKeyValidator { redisCache .flatMap(cache -> cache.loadSnapshot("")) .ifPresent(tools -> { - snapshotsByRoute.putIfAbsent("", List.copyOf(tools)); + // Redis snapshot은 아직 원천 지문으로 격리되지 않는다. 현재 원천 지문을 붙여 적재하므로 + // Redis를 사용할 경우 옛 endpoint가 되살아날 수 있다. Redis 활성화 전에 지문 기반 cache 격리가 필요하다. + snapshotsByRoute.putIfAbsent( + "", + new ToolSourceSnapshot( + "", registryClient.currentSourceFingerprint(""), tools)); manifestRefreshAttemptsByRoute.putIfAbsent("", currentTimeMillis.getAsLong()); }); } @@ -267,12 +317,16 @@ public class ToolRegistryService implements McpRouteKeyValidator { * 현재 memory에 알려진 모든 route를 요청 시점 또는 기동 preload에서 갱신합니다. Registry client가 전체 route snapshot을 제공하면 한 번에 반영하고, 그렇지 않으면 기존 route별 refresh를 수행합니다. */ public void refreshKnownRoutes() { - Map> snapshots = registryClient.fetchAllTools(); + Map snapshots = registryClient.fetchAllToolSnapshots(); 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)); + snapshots.forEach( + (routeKey, snapshot) -> { + if (replaceSnapshot(routeKey, snapshot)) { + manifestRefreshAttemptsByRoute.put(routeKey, now); + } + }); return; } List routeKeys = snapshotsByRoute.isEmpty() @@ -439,50 +493,105 @@ public class ToolRegistryService implements McpRouteKeyValidator { */ private List refreshOnce(String routeKey) { try { - List tools = - registryClient.fetchTools(routeKey).stream().filter(ToolMetadata::enabled).toList(); + ToolSourceSnapshot fetched = registryClient.fetchToolSnapshot(routeKey); + List tools = fetched.tools().stream().filter(ToolMetadata::enabled).toList(); if (removedRoutes.contains(routeKey)) { throw new JsonRpcException( JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "Portal registry route was removed: " + routeKey); } - List immutableTools = List.copyOf(tools); - List previous = snapshotsByRoute.put(routeKey, immutableTools); - logSnapshot(routeKey, previous, immutableTools); - publishListChangedIfNeeded(routeKey, previous, immutableTools); - redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, tools)); - return tools; + ToolSourceSnapshot candidate = + new ToolSourceSnapshot(routeKey, fetched.sourceFingerprint(), tools); + if (!replaceSnapshot(routeKey, candidate)) { + // 조회 도중 원천이 바뀐 결과다. memory에 반영하지 않고, TTL 시각도 되돌려 즉시 다시 조회할 수 있게 한다. + manifestRefreshAttemptsByRoute.remove(routeKey); + logStaleDiscard(routeKey); + throw new JsonRpcException( + JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, + "Tool source changed while fetching route: " + routeKey); + } + return candidate.tools(); } catch (RuntimeException exception) { if (removedRoutes.contains(routeKey)) { throw exception; } - List memory = snapshotsByRoute.get(routeKey); + ToolSourceSnapshot memory = snapshotsByRoute.get(routeKey); + if (memory != null && matchesCurrentSource(routeKey, memory)) { + return memory.tools(); + } if (memory != null) { - return memory; + discardStaleSnapshot(routeKey, memory); + throw exception; } Optional> shared = redisCache.flatMap(cache -> cache.loadSnapshot(routeKey)); if (shared.isPresent()) { - snapshotsByRoute.put(routeKey, List.copyOf(shared.get())); + // Redis snapshot은 아직 원천 지문으로 격리되지 않는다. Redis 활성화 전에 지문 기반 cache 격리가 필요하다. + ToolSourceSnapshot restored = + new ToolSourceSnapshot( + routeKey, registryClient.currentSourceFingerprint(routeKey), shared.get()); + snapshotsByRoute.put(routeKey, restored); manifestRefreshAttemptsByRoute.put(routeKey, currentTimeMillis.getAsLong()); - return shared.get(); + return restored.tools(); } throw exception; } } /** - * 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다. 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로나 Redis 경로를 다시 건드리지 않고, 기존 snapshot과 비교해 로그와 변경 이벤트만 처리합니다. + * 원천 세대가 바뀌어 조회 결과를 폐기했다는 사실을 기록합니다. 같은 route에서 반복되면 cooldown 안에서 WARN으로 올립니다. * * @param routeKey 처리 대상 route key입니다. - * @param tools 처리 대상 목록입니다. */ - private void replaceSnapshot(String routeKey, List tools) { - List immutableTools = List.copyOf(tools); - List previous = snapshotsByRoute.put(routeKey, immutableTools); - logSnapshot(routeKey, previous, immutableTools); - publishListChangedIfNeeded(routeKey, previous, immutableTools); - redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools)); + private void logStaleDiscard(String routeKey) { + long now = currentTimeMillis.getAsLong(); + Long previousLogAt = staleDiscardLogAtByRoute.putIfAbsent(routeKey, now); + if (previousLogAt == null) { + log.info( + "Tool source result discarded because the source generation changed. routeKey={}", + routeKey); + return; + } + if (now - previousLogAt < STALE_DISCARD_WARN_COOLDOWN_MILLIS + || !staleDiscardLogAtByRoute.replace(routeKey, previousLogAt, now)) { + return; + } + log.warn( + "Tool source result discarded repeatedly because the source generation changed. routeKey={}", + routeKey); + } + + /** + * 조회 결과를 route memory snapshot으로 반영합니다. 저장 직전에 조회에 사용한 원천 지문과 현재 원천 지문을 비교해, 조회 도중 원천이 바뀐 결과는 반영하지 않습니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param candidate 반영할 조회 결과입니다. + * @return 실제로 반영했으면 {@code true}, 원천 세대가 달라 폐기했으면 {@code false}입니다. + */ + private boolean replaceSnapshot(String routeKey, ToolSourceSnapshot candidate) { + AtomicReference previousHolder = new AtomicReference<>(); + AtomicBoolean stored = new AtomicBoolean(false); + SourceFingerprint currentFingerprint = registryClient.currentSourceFingerprint(routeKey); + snapshotsByRoute.compute( + routeKey, + (key, previous) -> { + // Map lock 안에서는 이미 계산한 지문만 비교해 파일 I/O나 원천 계산이 lock을 오래 점유하지 않게 한다. + if (!candidate.sourceFingerprint().equals(currentFingerprint)) { + return previous; + } + previousHolder.set(previous); + stored.set(true); + return candidate; + }); + if (!stored.get()) { + return false; + } + ToolSourceSnapshot previous = previousHolder.get(); + List previousTools = previous == null ? null : previous.tools(); + logSnapshot(routeKey, previousTools, candidate.tools()); + publishListChangedIfNeeded(routeKey, previousTools, candidate.tools()); + redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, candidate.tools())); + return true; } /** @@ -495,6 +604,7 @@ public class ToolRegistryService implements McpRouteKeyValidator { snapshotsByRoute.remove(routeKey); manifestRefreshAttemptsByRoute.remove(routeKey); portalChangesByRoute.remove(routeKey); + staleDiscardLogAtByRoute.remove(routeKey); } /** diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSourceSnapshot.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSourceSnapshot.java new file mode 100644 index 0000000..22ceb6d --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSourceSnapshot.java @@ -0,0 +1,35 @@ +package io.shinhanlife.dat.biz.mcp.registry; + +import java.util.List; + +/** + * @package io.shinhanlife.dat.biz.mcp.registry + * @className ToolSourceSnapshot + * @description 한 번의 원천 조회 결과를 그 조회에 사용한 원천 지문과 함께 담습니다. 조회 도중 원천이 바뀌면 지문이 달라지므로, 늦게 도착한 옛 세대 결과를 memory snapshot으로 승격하지 않을 수 있습니다. + * @author j.h.w + * @create 2026.09.18 + * + *
+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.18   j.h.w         최초생성
+ *
+ * 
+ */ +public record ToolSourceSnapshot( + String routeKey, + SourceFingerprint sourceFingerprint, + List tools) { + + /** + * 지문이 없는 snapshot을 허용하지 않으며 Tool 목록은 불변 복사본으로 보관합니다. + */ + public ToolSourceSnapshot { + if (sourceFingerprint == null) { + throw new IllegalArgumentException("source fingerprint is required"); + } + routeKey = routeKey == null ? "" : routeKey; + tools = tools == null ? List.of() : List.copyOf(tools); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67c5267..63673a3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -78,6 +78,9 @@ mcp: - 503 - 504 redis: + # Redis Tool snapshot은 아직 원천 지문(source fingerprint)으로 격리되지 않는다. + # 격리 없이 활성화하면 Tool Service 주소가 바뀐 뒤에도 옛 endpoint가 담긴 snapshot이 복원될 수 있다. + # Keep disabled until Redis Tool snapshots are isolated by source fingerprint. enabled: ${MCP_REDIS_ENABLED:false} key-prefix: axhub:mcp:tools # Portal writes the endpoint registry JSON here. MCP reads it only when the diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java index 659a957..0bf508d 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/LocalFileToolRegistryClientTest.java @@ -3,11 +3,20 @@ package io.shinhanlife.dat.biz.mcp.registry; import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER; import static io.shinhanlife.dat.biz.mcp.TestFixtures.properties; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.List; - import org.junit.jupiter.api.Test; import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; class LocalFileToolRegistryClientTest { @@ -29,4 +38,50 @@ class LocalFileToolRegistryClientTest { assertThat(tool.endpoint()).isEqualTo("http://localhost:18080/mcp"); }); } + + @Test + void reusesTheInMemoryFingerprintWithoutReadingTheFileOnEveryCheck() throws Exception { + String document = """ + { + "tools": [ + { + "name": "core.weather", + "description": "weather", + "inputSchema": {"type": "object"}, + "_meta": { + "endpoint": "http://localhost:18080/mcp", + "enabled": true + } + } + ] + } + """; + ResourceLoader resourceLoader = mock(ResourceLoader.class); + Resource resource = mock(Resource.class); + when(resourceLoader.getResource(anyString())).thenReturn(resource); + when(resource.getInputStream()) + .thenAnswer(ignored -> new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8))); + LocalFileToolRegistryClient client = + new LocalFileToolRegistryClient(resourceLoader, OBJECT_MAPPER, properties(false, false)); + + SourceFingerprint beforeLoad = client.currentSourceFingerprint(""); + SourceFingerprint repeatedBeforeLoad = client.currentSourceFingerprint(""); + + verifyNoInteractions(resourceLoader); + assertThat(repeatedBeforeLoad).isEqualTo(beforeLoad); + + ToolSourceSnapshot snapshot = client.fetchToolSnapshot(""); + + assertThat(snapshot.sourceFingerprint()).isEqualTo(client.currentSourceFingerprint("")); + assertThat(snapshot.sourceFingerprint()).isNotEqualTo(beforeLoad); + assertThat(snapshot.tools()).hasSize(1); + verify(resourceLoader, times(1)).getResource(anyString()); + verify(resource, times(1)).getInputStream(); + + client.currentSourceFingerprint(""); + client.currentSourceFingerprint(""); + + verify(resourceLoader, times(1)).getResource(anyString()); + verify(resource, times(1)).getInputStream(); + } } diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java index f60caf3..89a7599 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolBundleDiscoveryTest.java @@ -11,6 +11,9 @@ import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode; import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException; import io.shinhanlife.dat.biz.mcp.registry.ToolBundleDiscovery.BundleStatus; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.List; @@ -19,6 +22,7 @@ import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; @@ -62,7 +66,7 @@ class ToolBundleDiscoveryTest { } @Test - void acceptsAbsoluteToolEndpointDeclaredByTheManifest() { + void acceptsAbsoluteToolEndpointOnThePortalServiceDomainOrigin() { alpha.enqueue( new MockResponse() .setHeader("Content-Type", "application/json") @@ -70,7 +74,7 @@ class ToolBundleDiscoveryTest { """ {"bundleId":"bundle-a","tools":[ {"name":"a.search","description":"search","inputSchema":{"type":"object"}, - "endpoint":"http://tool-runtime.internal/mcp/a.search", + "endpoint":"http://tool-a/mcp/a.search", "_meta":{"version":"1.0.0"}}]} """)); McpProperties properties = @@ -80,7 +84,29 @@ class ToolBundleDiscoveryTest { assertThat(tools) .singleElement() - .satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-runtime.internal/mcp/a.search")); + .satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp/a.search")); + } + + @Test + void rejectsAbsoluteToolEndpointOnAnotherOrigin() { + alpha.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody( + """ + {"bundleId":"bundle-a","tools":[ + {"name":"a.search","description":"search","inputSchema":{"type":"object"}, + "endpoint":"http://untrusted.internal/mcp/a.search", + "_meta":{"version":"1.0.0"}}]} + """)); + McpProperties properties = + withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a.")); + + assertThatThrownBy(() -> client(properties).fetchTools()) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); } @Test @@ -341,6 +367,126 @@ class ToolBundleDiscoveryTest { }); } + @Test + void keepsLastGoodWhenTheSameSourceAddressFailsTemporarily(@TempDir Path tempDir) throws IOException { + Path manifestFile = tempDir.resolve("bundle-a.json"); + Files.writeString(manifestFile, manifestJson("bundle-a", "a.search")); + McpProperties properties = withBundles(); + ToolBundleDiscovery discovery = discovery(properties); + McpProperties.Bundle target = + bundle("bundle-a", fileUrl(manifestFile), "http://old-server/mcp", "a."); + assertThat(discovery.discoverAll(List.of(target))).singleElement() + .satisfies(result -> assertThat(result.usableSnapshot()).isTrue()); + + Files.delete(manifestFile); + + assertThat(discovery.discoverAll(List.of(target))) + .singleElement() + .satisfies( + result -> { + assertThat(result.usableSnapshot()).isTrue(); + assertThat(result.tools()) + .singleElement() + .satisfies( + tool -> + assertThat(tool.endpoint()) + .isEqualTo("http://old-server/mcp/a.search")); + }); + } + + @Test + void doesNotReturnToolsOfThePreviousAddressWhenTheSourceMovesAndTheNewFetchFails( + @TempDir Path tempDir) throws IOException { + Path oldManifest = tempDir.resolve("old.json"); + Files.writeString(oldManifest, manifestJson("bundle-a", "a.search")); + ToolBundleDiscovery discovery = discovery(withBundles()); + discovery.discoverAll( + List.of(bundle("bundle-a", fileUrl(oldManifest), "http://old-server/mcp", "a."))); + + McpProperties.Bundle relocated = + bundle( + "bundle-a", + fileUrl(tempDir.resolve("missing.json")), + "http://new-server/mcp", + "a."); + + assertThat(discovery.discoverAll(List.of(relocated))) + .singleElement() + .satisfies( + result -> { + assertThat(result.usableSnapshot()).isFalse(); + assertThat(result.tools()).isEmpty(); + }); + } + + @Test + void usesOnlyTheNewAddressAfterTheSourceMovesAndTheNewFetchSucceeds(@TempDir Path tempDir) + throws IOException { + Path oldManifest = tempDir.resolve("old.json"); + Path newManifest = tempDir.resolve("new.json"); + Files.writeString(oldManifest, manifestJson("bundle-a", "a.search")); + Files.writeString(newManifest, manifestJson("bundle-a", "a.search")); + ToolBundleDiscovery discovery = discovery(withBundles()); + discovery.discoverAll( + List.of(bundle("bundle-a", fileUrl(oldManifest), "http://old-server/mcp", "a."))); + + List results = + discovery.discoverAll( + List.of( + bundle( + "bundle-a", + fileUrl(newManifest), + "http://new-server/mcp", + "a."))); + + assertThat(results) + .singleElement() + .satisfies( + result -> { + assertThat(result.usableSnapshot()).isTrue(); + assertThat(result.tools()) + .allSatisfy( + tool -> + assertThat(tool.endpoint()) + .isEqualTo("http://new-server/mcp/a.search")); + }); + } + + @Test + void changesTheSourceFingerprintWhenOnlyTheBundleAddressChanges() { + SourceFingerprint first = + ToolBundleDiscovery.fingerprintOf( + List.of(bundle("bundle-a", "http://old/manifest", "http://old-server/mcp", "a."))); + SourceFingerprint second = + ToolBundleDiscovery.fingerprintOf( + List.of(bundle("bundle-a", "http://old/manifest", "http://new-server/mcp", "a."))); + SourceFingerprint reordered = + ToolBundleDiscovery.fingerprintOf( + List.of( + bundle("bundle-b", "http://b/manifest", "http://b-server/mcp", "b."), + bundle("bundle-a", "http://old/manifest", "http://old-server/mcp", "a."))); + SourceFingerprint sameSet = + ToolBundleDiscovery.fingerprintOf( + List.of( + bundle("bundle-a", "http://old/manifest", "http://old-server/mcp", "a."), + bundle("bundle-b", "http://b/manifest", "http://b-server/mcp", "b."))); + + assertThat(first).isNotEqualTo(second); + assertThat(reordered).isEqualTo(sameSet); + } + + private String manifestJson(String bundleId, String toolName) { + return """ + {"bundleId":"%s","tools":[ + {"name":"%s","description":"desc","inputSchema":{"type":"object"}, + "_meta":{"version":"1.0.0","endpoint":"/mcp/%s"}}]}""" + .formatted(bundleId, toolName, toolName); + } + + private String fileUrl(Path path) { + return path.toUri().toString(); + } + private MockResponse manifest(String bundleId, String... toolNames) { StringBuilder tools = new StringBuilder(); for (String toolName : toolNames) { 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 95fa745..dc97dfa 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 @@ -6,7 +6,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -25,7 +27,9 @@ import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.context.ApplicationEventPublisher; @@ -34,7 +38,7 @@ class ToolRegistryServiceTest { @Test void usesMemorySnapshotWithoutTouchingRedisOrSourceOnTheRequestPath() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(client.fetchTools("")).thenReturn(List.of(tool("http://cached-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); @@ -43,12 +47,15 @@ class ToolRegistryServiceTest { assertThat(service.listTools()).hasSize(1); - verifyNoInteractions(client, redis); + verify(client).currentSourceFingerprint(""); + verify(client, never()).fetchTools(""); + verify(client, never()).fetchToolSnapshot(""); + verifyNoInteractions(redis); } @Test void keepsPreviousSnapshotWhenSourceRefreshFails() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(client.fetchTools("")) .thenReturn(List.of(tool("http://memory-tool"))) @@ -65,7 +72,7 @@ class ToolRegistryServiceTest { @Test void adoptsSharedSnapshotWhenFirstSourceFetchFails() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(client.fetchTools("")) .thenThrow( @@ -83,7 +90,7 @@ class ToolRegistryServiceTest { @Test void usesRouteSpecificSharedSnapshotWhenRouteRefreshFails() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(client.fetchTools("external")) .thenThrow(new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, "registry down")); @@ -98,7 +105,7 @@ class ToolRegistryServiceTest { } @Test void sharesOneSourceFetchAcrossConcurrentRefreshCalls() throws Exception { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); when(client.fetchTools("")) @@ -124,7 +131,7 @@ class ToolRegistryServiceTest { @Test void sharesOnePortalRefreshAcrossConcurrentRequests() throws Exception { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -156,7 +163,7 @@ class ToolRegistryServiceTest { @Test void propagatesSourceFailureWhenNoSnapshotExists() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(client.fetchTools("")) .thenThrow( @@ -174,7 +181,7 @@ class ToolRegistryServiceTest { @Test void warmStartsFromSharedCacheOnlyBeforeMemoryIsLoaded() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(redis.loadSnapshot("")).thenReturn(Optional.of(List.of(tool("http://shared-tool")))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); @@ -187,12 +194,14 @@ class ToolRegistryServiceTest { .extracting(ToolMetadata::endpoint) .isEqualTo("http://shared-tool"); verify(redis, times(1)).loadSnapshot(""); - verifyNoInteractions(client); + verify(client, times(2)).currentSourceFingerprint(""); + verify(client, never()).fetchTools(""); + verify(client, never()).fetchToolSnapshot(""); } @Test void writesSharedCacheOnlyAfterSuccessfulSourceFetch() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class); when(client.fetchTools("")).thenReturn(List.of(tool("http://direct-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis)); @@ -205,7 +214,7 @@ class ToolRegistryServiceTest { @Test void treatsASuccessfulEmptyCatalogAsAUsableSnapshot() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.fetchTools("")).thenReturn(List.of()); ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); @@ -217,7 +226,7 @@ class ToolRegistryServiceTest { @Test void resolvesEnabledToolByItsStandardName() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.fetchTools("")).thenReturn(List.of(tool("http://cached-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); @@ -226,7 +235,7 @@ class ToolRegistryServiceTest { @Test void throttlesRepeatedManifestRefreshForMissingToolByRoute() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); when(client.fetchTools("external")).thenReturn(List.of(tool("http://cached-tool"))); ToolRegistryService service = serviceWithClock(client, properties(false, false), now); @@ -249,7 +258,7 @@ class ToolRegistryServiceTest { @Test void keepsIndependentSnapshotsPerRoute() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.fetchTools("external")).thenReturn(List.of(tool("http://external-tool"))); when(client.fetchTools("sms")).thenReturn(List.of(tool("http://sms-tool"))); ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); @@ -266,7 +275,7 @@ class ToolRegistryServiceTest { @Test void keepsMemorySnapshotWhenManifestTtlHasNotExpired() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); when(client.fetchTools("external")).thenReturn(List.of(tool("http://first-tool"))); ToolRegistryService service = serviceWithClock(client, properties(false, false), now); @@ -278,12 +287,14 @@ class ToolRegistryServiceTest { .extracting(ToolMetadata::endpoint) .isEqualTo("http://first-tool"); - verifyNoInteractions(client); + verify(client).currentSourceFingerprint("external"); + verify(client, never()).fetchTools("external"); + verify(client, never()).fetchToolSnapshot("external"); } @Test void refreshesManifestWhenRequestArrivesAfterManifestTtl() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); when(client.fetchTools("external")) .thenReturn(List.of(tool("http://first-tool"))) @@ -302,7 +313,7 @@ class ToolRegistryServiceTest { @Test void refreshesManifestImmediatelyWhenPortalRegistryChangesAtRequestTime() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); McpProperties mcpProperties = propertiesWithTtl(true, 9_999, 1); when(client.fetchTools("external")) @@ -326,7 +337,7 @@ class ToolRegistryServiceTest { @Test void refreshesOnlyTheRouteWhosePortalRevisionChanged() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); McpProperties mcpProperties = propertiesWithTtl(true, 9_999, 1); when(client.fetchTools("external")) @@ -357,7 +368,7 @@ class ToolRegistryServiceTest { @Test void removesMemorySnapshotWhenPortalRouteIsDisabled() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.fetchTools("external")).thenReturn(List.of(tool("http://external-tool"))); when(client.refreshSourceRegistry()).thenReturn(true); when(client.changedSourceRouteKeys()).thenReturn(Set.of("external")); @@ -373,7 +384,7 @@ class ToolRegistryServiceTest { @Test void allowsFreshSnapshotWhenPortalRouteIsEnabledAgain() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.fetchTools("external")) .thenReturn(List.of(tool("http://before-disable"))) .thenReturn(List.of(tool("http://after-enable"))); @@ -397,7 +408,7 @@ class ToolRegistryServiceTest { @Test void refreshesAllPortalRoutesFromOneAggregateRegistrySnapshot() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.fetchAllTools()).thenReturn(Map.of( "external", List.of(tool("http://external-tool")), "business", List.of(tool("http://business-tool")))); @@ -419,7 +430,7 @@ class ToolRegistryServiceTest { @Test void publishesToolsListChangedOnlyWhenExistingRouteSnapshotChanges() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); when(client.fetchTools("external")) .thenReturn(List.of(tool("http://first-tool"))) @@ -443,7 +454,7 @@ class ToolRegistryServiceTest { @Test void retriesPortalRegistryEveryTenSecondsUntilTheFirstLoadSucceeds() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); when(client.isSourceRegistryAwaitingInitialLoad()).thenReturn(true); when(client.refreshSourceRegistry()).thenThrow(new IllegalStateException("portal down")); @@ -462,7 +473,7 @@ class ToolRegistryServiceTest { @Test void usesPortalTtlOnceThePortalRegistryHasBeenLoaded() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); AtomicLong now = new AtomicLong(0); when(client.isSourceRegistryAwaitingInitialLoad()).thenReturn(false); when(client.refreshSourceRegistry()).thenReturn(false); @@ -480,7 +491,7 @@ class ToolRegistryServiceTest { @Test void doesNotReportInitialLoadWaitOutsidePortalMode() { - ToolRegistryClient client = mock(ToolRegistryClient.class); + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); when(client.isSourceRegistryAwaitingInitialLoad()).thenReturn(true); ToolRegistryService service = serviceWithClock(client, propertiesWithTtl(false, 300, 300), new AtomicLong(0)); @@ -488,6 +499,147 @@ class ToolRegistryServiceTest { assertThat(service.isSourceRegistryAwaitingInitialLoad()).isFalse(); } + @Test + void doesNotServeTheMemorySnapshotAfterTheSourceGenerationChanges() { + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); + AtomicLong now = new AtomicLong(0); + AtomicReference current = new AtomicReference<>(fingerprint("gen-1")); + when(client.currentSourceFingerprint(any())).thenAnswer(invocation -> current.get()); + when(client.fetchTools("external")) + .thenReturn(List.of(tool("http://old-server/mcp/search"))) + .thenReturn(List.of(tool("http://new-server/mcp/search"))); + ToolRegistryService service = + serviceWithClock(client, propertiesWithTtl(false, 9_999, 1), now); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://old-server/mcp/search"); + + current.set(fingerprint("gen-2")); + + assertThat(service.listTools("external")) + .singleElement() + .extracting(ToolMetadata::endpoint) + .isEqualTo("http://new-server/mcp/search"); + verify(client, times(2)).fetchTools("external"); + } + + @Test + void discardsAFetchResultWhoseSourceChangedDuringTheFetchAndRetriesImmediately() { + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); + AtomicLong now = new AtomicLong(0); + AtomicInteger generation = new AtomicInteger(1); + AtomicReference current = + new AtomicReference<>(fingerprint("gen-" + generation.get())); + when(client.currentSourceFingerprint(any())).thenAnswer(invocation -> current.get()); + doAnswer( + invocation -> { + ToolSourceSnapshot fetched = + new ToolSourceSnapshot( + "external", + current.get(), + List.of(tool("http://old-server/mcp/search"))); + current.set(fingerprint("gen-" + generation.incrementAndGet())); + return fetched; + }) + .when(client) + .fetchToolSnapshot("external"); + ToolRegistryService service = + serviceWithClock(client, propertiesWithTtl(false, 9_999, 1), now); + + assertThatThrownBy(() -> service.listTools("external")) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + assertThat(service.hasUsableSnapshot()).isFalse(); + + assertThatThrownBy(() -> service.listTools("external")).isInstanceOf(JsonRpcException.class); + + // TTL 시각을 갱신하지 않으므로 같은 시각에도 즉시 다시 조회한다. + verify(client, times(2)).fetchToolSnapshot("external"); + } + + @Test + void doesNotReportAStaleSourceGenerationAsUsable() { + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); + AtomicReference current = new AtomicReference<>(fingerprint("gen-1")); + when(client.currentSourceFingerprint(any())).thenAnswer(invocation -> current.get()); + when(client.fetchTools("external")) + .thenReturn(List.of(tool("http://old-server/mcp/search"))); + ToolRegistryService service = new ToolRegistryService(client, Optional.empty()); + service.refresh("external"); + + assertThat(service.hasUsableSnapshot()).isTrue(); + + current.set(fingerprint("gen-2")); + + assertThat(service.hasUsableSnapshot()).isFalse(); + } + + @Test + void refetchesOnlyOnceWhenTheSourceKeepsChanging() { + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); + AtomicLong now = new AtomicLong(0); + AtomicReference current = new AtomicReference<>(fingerprint("gen-1")); + when(client.currentSourceFingerprint(any())).thenAnswer(invocation -> current.get()); + when(client.fetchToolSnapshot("external")) + .thenAnswer( + invocation -> + new ToolSourceSnapshot( + "external", + current.get(), + List.of(tool("http://old-server/mcp/search")))); + ToolRegistryService service = + serviceWithClock(client, propertiesWithTtl(false, 9_999, 1), now); + assertThat(service.listTools("external")).hasSize(1); + clearInvocations(client); + + when(client.fetchToolSnapshot("external")) + .thenAnswer( + invocation -> + new ToolSourceSnapshot( + "external", + fingerprint("gen-1"), + List.of(tool("http://old-server/mcp/search")))); + current.set(fingerprint("gen-2")); + + assertThatThrownBy(() -> service.listTools("external")) + .isInstanceOfSatisfying( + JsonRpcException.class, + exception -> + assertThat(exception.errorCode()) + .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE)); + verify(client, times(1)).fetchToolSnapshot("external"); + } + + @Test + void doesNotAdoptAggregateSnapshotsThatBelongToAnOlderSourceGeneration() { + ToolRegistryClient client = mock(ToolRegistryClient.class, CALLS_REAL_METHODS); + AtomicLong now = new AtomicLong(0); + when(client.currentSourceFingerprint(any())).thenReturn(fingerprint("gen-2")); + when(client.fetchAllToolSnapshots()) + .thenReturn( + Map.of( + "external", + new ToolSourceSnapshot( + "external", + fingerprint("gen-1"), + List.of(tool("http://old-server/mcp/search"))))); + ToolRegistryService service = + serviceWithClock(client, propertiesWithTtl(false, 9_999, 1), now); + + service.refreshKnownRoutes(); + + assertThat(service.hasUsableSnapshot()).isFalse(); + } + + private SourceFingerprint fingerprint(String generation) { + return SourceFingerprint.of(List.of(generation)); + } + private ToolRegistryService serviceWithClock( ToolRegistryClient client, McpProperties properties, AtomicLong now) { return new ToolRegistryService(