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 a93b8fa..759cfde 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 @@ -103,7 +103,7 @@ public class PortalToolRegistryClient implements ToolRegistryClient { } /** - * 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다. + * 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다. 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회하되, 한 route의 실패가 다른 정상 route의 manifest 조회를 막지 않도록 route 단위로 실패를 격리합니다. * * @return 조회 또는 변환된 목록 정보를 반환합니다. */ @@ -111,7 +111,8 @@ public class PortalToolRegistryClient implements ToolRegistryClient { public Map> fetchAllTools() { ensurePortalRegistryLoaded(); Map> snapshots = new LinkedHashMap<>(); - bundlesByRoute.forEach((routeKey, bundles) -> snapshots.put(routeKey, fetchRouteTools(routeKey, bundles))); + bundlesByRoute.forEach((routeKey, bundles) -> + fetchRouteToolsSafely(routeKey, bundles).ifPresent(tools -> snapshots.put(routeKey, tools))); return Map.copyOf(snapshots); } @@ -313,6 +314,26 @@ public class PortalToolRegistryClient implements ToolRegistryClient { return merge(results); } + /** + * 전체 route preload 중 한 route의 Tool Service manifest 조회 실패를 해당 route로만 제한합니다. 물리 MCP 한 대가 여러 논리 route를 담는 구성에서는 하나의 route 장애 때문에 다른 route의 정상 Tool 목록까지 누락되면 안 되므로, 실패 route는 로그만 남기고 성공 route snapshot 수집을 계속합니다. + * + * @param routeKey 처리 대상 route key입니다. + * @param bundles 처리 대상 목록입니다. + * @return 조회된 선택값을 반환합니다. + */ + private Optional> fetchRouteToolsSafely(String routeKey, List bundles) { + try { + return Optional.of(fetchRouteTools(routeKey, bundles)); + } catch (RuntimeException exception) { + log.warn( + "Portal route manifest refresh ignored: routeKey={}, reason={}, message={}", + routeKey, + exception.getClass().getSimpleName(), + exception.getMessage()); + return Optional.empty(); + } + } + /** * Tool Server 한 대의 routing manifest API를 호출합니다. 응답 JSON shape는 Tool Server와 Agent Builder의 계약이므로 MCP가 필드명을 변환하지 않고 원문 JSON tree를 그대로 반환합니다. * diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java index 064f7bc..85ee67a 100644 --- a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/PortalToolRegistryClientTest.java @@ -177,6 +177,31 @@ class PortalToolRegistryClientTest { assertThat(toolServer.getRequestCount()).isEqualTo(1); } + @Test + void keepsUsableRoutesWhenAnotherRouteManifestIsUnavailable() throws Exception { + MockWebServer unreachableToolServer = new MockWebServer(); + unreachableToolServer.start(); + try { + portal.enqueue(portalRegistryWithOneBrokenAndThreeUsableRoutes("portal-1", unreachableToolServer)); + unreachableToolServer.enqueue(new MockResponse().setResponseCode(503)); + toolServer.enqueue(manifestForBundle("was-cus", "cus.customer")); + toolServer.enqueue(manifestForBundle("was-pro", "pro.product")); + toolServer.enqueue(manifestForBundle("was-sys", "sys.health")); + PortalToolRegistryClient client = client(); + + Map> snapshots = client.fetchAllTools(); + + assertThat(snapshots).containsOnlyKeys("cus", "pro", "sys"); + assertThat(snapshots.get("cus")).extracting(ToolMetadata::name).containsExactly("cus.customer"); + assertThat(snapshots.get("pro")).extracting(ToolMetadata::name).containsExactly("pro.product"); + assertThat(snapshots.get("sys")).extracting(ToolMetadata::name).containsExactly("sys.health"); + assertThat(unreachableToolServer.getRequestCount()).isEqualTo(1); + assertThat(toolServer.getRequestCount()).isEqualTo(3); + } finally { + unreachableToolServer.shutdown(); + } + } + @Test void fetchesRoutingManifestForTheRequestedPortalRoute() throws Exception { portal.enqueue(portalRegistry("portal-1")); @@ -393,6 +418,62 @@ class PortalToolRegistryClientTest { """ .formatted(revision, toolServer.url("").toString().replaceAll("/+$", ""))); } + + private MockResponse portalRegistryWithOneBrokenAndThreeUsableRoutes( + String revision, MockWebServer unreachableToolServer) { + String usableDomain = toolServer.url("").toString().replaceAll("/+$", ""); + return jsonResponse( + """ + { + "registryRevision": "%s", + "routes": [ + { + "routeKey": "sal", + "toolServices": [ { + "serviceKey": "was-sal", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "status": "ACTIVE" + } ] + }, + { + "routeKey": "cus", + "toolServices": [ { + "serviceKey": "was-cus", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "status": "ACTIVE" + } ] + }, + { + "routeKey": "pro", + "toolServices": [ { + "serviceKey": "was-pro", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "status": "ACTIVE" + } ] + }, + { + "routeKey": "sys", + "toolServices": [ { + "serviceKey": "was-sys", + "serviceDomain": "%s", + "manifestPath": "/tool-manifest", + "status": "ACTIVE" + } ] + } + ] + } + """ + .formatted( + revision, + unreachableToolServer.url("").toString().replaceAll("/+$", ""), + usableDomain, + usableDomain, + usableDomain)); + } + private MockResponse manifest(String revision, String toolName) { return manifestForBundle("external-tool-server", revision, toolName); }