diff --git a/README.md b/README.md
index b2d6654..c84344a 100644
--- a/README.md
+++ b/README.md
@@ -157,6 +157,7 @@ deployments:
| [architecture.md](docs/architecture.md) | 현재 코드 구조, 요청 흐름, 내부 책임과 장애 동작 |
| [Agent Builder-MCP contracts](docs/contracts/agent-builder-mcp/README.md) | Agent Builder와의 HTTP/JSON-RPC wire 계약 |
| [Tool Service-MCP contracts](docs/contracts/tool-service-mcp/README.md) | 매니페스트와 Tool 실행 wire 계약 |
+| [Portal-MCP contracts](docs/contracts/portal-mcp/README.md) | Portal registry의 Tool Server endpoint 목록 조회 wire 계약 |
| [decisions](docs/decisions/README.md) | 결정 이유와 대안 이력 |
| [extension-points.md](docs/extension-points.md) | 아직 미합의인 항목과 운영 보완 작업 |
| [codex-workflow.md](docs/codex-workflow.md) | 저장소 작업 규칙과 공개 정책 |
diff --git a/deploy/README.md b/deploy/README.md
index 03e4615..0353fc1 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -3,6 +3,14 @@
이 디렉터리는 **배포될 대상**을 정의한다. 빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며
이 저장소가 정하지 않는다.
+> **내부망 운영은 이 Chart를 사용하지 않는다**([ADR-0010](../docs/decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md) 결정 7).
+> 여기 정의된 토폴로지는 배포 하나가 Tool Service 하나를 보는 `mcp.bundles` 구성(ADR-0007/0009)을 전제한다.
+> 내부망 운영은 endpoint 목록과 route 매핑의 원천을 Portal로 옮겼고, 배포 하나가 N개 route를 서비스한다.
+>
+> Chart를 지우지 않는 이유는 `mcp.bundles` 구성이 코드에서 사라지지 않았고 local 검증과 1:1 배포가
+> 필요한 환경에서 그대로 유효하기 때문이다. **다만 아래 `deployments` 목록의 업무 이름은 예시이며
+> 실제 배포 대상이 아니다.** Portal 구성으로 갈 환경에 이 Chart를 적용하면 route가 하나로 고정된다.
+
## Helm Chart
[helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다. values는 두 축으로 나뉜다.
diff --git a/deploy/helm/mcp-server/values.yaml b/deploy/helm/mcp-server/values.yaml
index 671b8d5..ce7611f 100644
--- a/deploy/helm/mcp-server/values.yaml
+++ b/deploy/helm/mcp-server/values.yaml
@@ -1,5 +1,10 @@
# 환경 공통 기본값과 배포 토폴로지. 환경별 차이는 values-{env}.yaml이 덮어쓴다.
#
+# 내부망 운영은 이 Chart를 사용하지 않는다(ADR-0010 결정 7). 아래 원칙과 deployments 목록은
+# 배포 하나가 Tool Service 하나를 보는 mcp.bundles 구성(ADR-0007/0009)을 전제한다.
+# Portal이 endpoint 원천인 구성에서는 배포 하나가 N개 route를 서비스하므로 이 토폴로지가 성립하지 않는다.
+# 자세한 배경은 deploy/README.md 머리말에 있다.
+#
# 이 Chart의 설계 원칙:
# 1. MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007).
# bundle 목록은 항상 한 항목이며 template이 만든다.
diff --git a/docs/architecture.md b/docs/architecture.md
index 0997bea..bb85425 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -204,9 +204,11 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server e
운영 profile에서는 `ToolBundleDiscovery`와 `ToolBundleRegistryClient`만 metadata 원천으로 활성화한다. MCP 배포별 `mcp.bundles`가 Tool Service의 매니페스트와 실행 주소를 선언한다. 운영 Helm 설정에는 fallback 파일을 넣지 않는다. Tool Service는 표준 `name`을 소유하고, MCP는 자기 Bundle 안에서 형식·설정된 `namePrefix`·중복을 검증하되 이름을 재작성하지 않는다. 서로 다른 MCP 배포 간 이름의 전역 유일성은 Tool Service·플랫폼의 변경 절차가 보장한다. Redis는 선택적인 공유 last-good cache일 뿐 Tool 목록의 원천이 아니다.
-**`mcp.bundles`는 N개를 지원하지만 운영 배포에서는 항상 한 항목이다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이다. 그래야 등급이 다른 Tool Service의 조회 실패가 서로의 카탈로그 갱신을 막지 않는다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
+**`mcp.bundles` 구성에서 이 목록은 항상 한 항목이었다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이었다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
-각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Java 애플리케이션에는 route table이나 다중 Registry를 추가하지 않는다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다.
+이 구성에서 각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다.
+
+**내부망 운영은 위 구성을 쓰지 않는다.** endpoint 목록과 route↔Tool Service 매핑의 원천을 Portal로 옮기고, 배포 하나가 N개 route를 서비스하며 route 하나에 N개 Tool Service가 붙을 수 있다([ADR-0010](decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md)). 이때 route key는 `mcp.endpoint-path`에 고정되지 않고 `/mcp/{routeKey}` URI에서 결정되며, 카탈로그 병합과 `max-tools-total` 상한은 route 단위로 적용된다. 배포별 분리가 사라지므로 connection pool·thread·재기동 영향은 전 route가 공유하고, readiness는 route 하나만 준비돼도 UP이 된다. 근거와 포기한 것은 ADR-0010에 있다.
운영 상태는 외부 ingress가 아니라 management port(기본 9090)의 `GET /actuator/toolBundles`로 확인한다.
diff --git a/docs/contracts/portal-mcp/README.md b/docs/contracts/portal-mcp/README.md
new file mode 100644
index 0000000..a7a9620
--- /dev/null
+++ b/docs/contracts/portal-mcp/README.md
@@ -0,0 +1,51 @@
+# Portal-MCP 계약 문서
+
+이 디렉터리는 Portal과 MCP Server 사이의 **Tool Server endpoint 목록 조회 계약**을 관리한다.
+
+```text
+Portal ──[portal-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Service
+ (endpoint 목록) (Tool 목록과 실행)
+```
+
+| 문서 | 상태 | 용도 |
+|---|---|---|
+| [protocol-v0.1-registry.md](protocol-v0.1-registry.md) | MCP 측 구현 완료, Portal 측 미합의 | Portal registry 조회 요청·응답과 실패 처리 계약 |
+
+## 이 계약이 존재하는 이유
+
+`mcp.bundles`로 배포 YAML에 Tool Service를 직접 선언하는 구성에서는 이 계약이 필요 없다.
+Portal이 route별 Tool Server 목록을 관리하는 구성(`mcp.portal.enabled=true`)에서만 사용하며,
+이때 Portal은 **endpoint 목록의 원천**이 된다.
+
+**내부망 운영은 이 구성을 채택했다**([ADR-0010](../../decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md)).
+따라서 이 계약은 선택 사항이 아니라 운영 경로의 정본이다. 배포 하나가 N개 route를 서비스하고
+route 하나에 N개 Tool Service가 붙을 수 있다.
+
+## 현재 원칙
+
+- Portal은 **어디에 Tool Server가 있는가**만 답한다. **어떤 Tool이 있는가**는 여전히 Tool Service 매니페스트가 답한다.
+- MCP는 Portal registry와 Tool Service 매니페스트를 **서로 다른 주기로** 조회한다.
+- Portal 조회 실패는 목록을 비우지 않는다. in-memory endpoint snapshot을 유지하고, cold start일 때만 Redis fallback을 읽는다.
+- 요청 경로(`tools/list`, `tools/call`)는 Portal을 호출하지 않는다. in-memory snapshot만 읽는다.
+- **이 구성에서 outbound 주소의 원천은 배포 YAML이 아니라 Portal이다.** 따라서 Portal은 신뢰 경계 안에 있어야 하며,
+ MCP→Portal 구간은 network 수준에서 제한한다. 근거와 요구사항은 [v0.1 계약 §8](protocol-v0.1-registry.md#8-보안-요구사항)에 있다.
+
+## 예제와 검증
+
+[examples/registry-v0.1](examples/registry-v0.1/)의 응답 JSON을 `PortalRegistryContractExampleTest`가 직접 읽어
+`PortalToolRegistryClient`의 실제 파싱 경로에 태운다. 예제와 구현은 같은 변경에서 함께 고친다.
+
+## 현재 producer는 외부망 검증용 PoC다
+
+운영 Portal은 아직 이 API를 제공하지 않는다. 현재 응답을 만드는 것은 외부망 통합 검증용 PoC Portal이며,
+이 계약 문서가 **PoC와 운영 Portal이 공유해야 할 유일한 정본**이다.
+PoC 구현이 저장소를 떠나도 이 문서와 예제는 남는다.
+
+운영 적용 전에 Portal 개발 파트와 다음 항목을 확정한다.
+
+1. Portal registry API의 인증 방식과 MCP→Portal NetworkPolicy
+2. Tool Service 매니페스트 조회용 credential 전달 경로 (현재 registry 응답에 없다, §9)
+3. `registryRevision` 채번 주체와 단조 증가 보장 범위
+4. route key 명명 규칙과 route 삭제 시 rolling 절차
+
+상세 필드와 실패 처리는 [v0.1 계약](protocol-v0.1-registry.md)을 따른다.
diff --git a/docs/contracts/portal-mcp/examples/registry-v0.1/aggregate-registry-response.json b/docs/contracts/portal-mcp/examples/registry-v0.1/aggregate-registry-response.json
new file mode 100644
index 0000000..1dbf176
--- /dev/null
+++ b/docs/contracts/portal-mcp/examples/registry-v0.1/aggregate-registry-response.json
@@ -0,0 +1,41 @@
+{
+ "registryRevision": 12,
+ "routes": [
+ {
+ "routeKey": "business",
+ "toolServices": [
+ {
+ "serviceKey": "business-tools",
+ "displayName": "Business Tool Server",
+ "serviceDomain": "http://tool-business.ax-hub.svc.cluster.local:8080",
+ "manifestPath": "/tool-manifest",
+ "executeBasePath": "",
+ "namePrefix": "business.",
+ "toolEndpoints": {
+ "business.customer_search": "/mcp/business.customer_search",
+ "business.order_status": "/mcp/business.order_status"
+ },
+ "status": "ACTIVE"
+ }
+ ]
+ },
+ {
+ "routeKey": "external",
+ "toolServices": [
+ {
+ "serviceKey": "external-tools",
+ "displayName": "External Tool Server",
+ "serviceDomain": "http://tool-external.ax-hub.svc.cluster.local:8080",
+ "manifestPath": "/tool-manifest",
+ "executeBasePath": "",
+ "namePrefix": "external.",
+ "toolEndpoints": {
+ "external.exchange_rate": "/mcp/external.exchange_rate",
+ "external.weather_lookup": "/mcp/external.weather_lookup"
+ },
+ "status": "ACTIVE"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/contracts/portal-mcp/examples/registry-v0.1/mcp-portal-config.yaml b/docs/contracts/portal-mcp/examples/registry-v0.1/mcp-portal-config.yaml
new file mode 100644
index 0000000..3d8c3c5
--- /dev/null
+++ b/docs/contracts/portal-mcp/examples/registry-v0.1/mcp-portal-config.yaml
@@ -0,0 +1,48 @@
+# MCP Server의 Portal registry 설정 예시 (protocol-v0.1-registry.md 3절)
+#
+# 이 파일은 계약 예시이며 실제 적용 설정이 아니다.
+# 운영에서는 ConfigMap으로 주입한다.
+#
+# 키 이름은 구현된 McpProperties와 1:1로 맞춰 두었다. Spring relaxed binding이
+# camelCase와 kebab-case를 모두 받으므로 이 문서는 application.yml과 같은 kebab-case를 쓴다.
+
+mcp:
+ portal:
+ # true일 때만 PortalToolRegistryClient가 등록된다.
+ # false면 아래 bundles 목록이 endpoint 원천이 된다.
+ enabled: true
+
+ # 반드시 집계 조회 endpoint를 가리킨다. route별 URL이나 {route} placeholder를 쓰지 않는다.
+ # 이유는 계약 2절에 있다.
+ registry-url: http://portal.ax-hub.svc.cluster.local:8080/api/portal/registry
+
+ # Portal registry 조회 주기. 매니페스트 조회 주기(mcp.registry)와 분리된다.
+ # registryRevision이 바뀌면 이 주기와 별개로 매니페스트 refresh가 즉시 한 번 더 돈다.
+ refresh-interval-seconds: 300
+
+ registry:
+ # 저장된 endpoint의 Tool 매니페스트를 다시 읽는 주기.
+ refresh-interval-seconds: 30
+ refresh-jitter-seconds: 5
+
+ discovery:
+ # Portal 구성에서도 매니페스트 조회·검증 경로는 그대로 사용한다.
+ enabled: true
+ connect-timeout-millis: 1000
+ read-timeout-millis: 3000
+ max-tools-per-bundle: 100
+ max-tools-total: 200
+ max-manifest-bytes: 1048576
+ max-tool-timeout-millis: 30000
+
+ redis:
+ enabled: true
+ key-prefix: axhub:mcp
+ # Portal registry 응답 JSON의 fallback key.
+ # route별 Tool snapshot key와 반드시 분리한다(계약 7절).
+ # 운영에서는 Portal이 쓰는 key와 값을 맞춘다.
+ portal-registry-key: axhub:mcp:portal-registry
+
+ # Portal이 endpoint 원천이므로 이 목록은 비운다.
+ # 원천이 둘이 되면 어느 쪽이 이겼는지 로그로 구분할 수 없다.
+ bundles: []
diff --git a/docs/contracts/portal-mcp/examples/registry-v0.1/route-registry-response.json b/docs/contracts/portal-mcp/examples/registry-v0.1/route-registry-response.json
new file mode 100644
index 0000000..439dd7d
--- /dev/null
+++ b/docs/contracts/portal-mcp/examples/registry-v0.1/route-registry-response.json
@@ -0,0 +1,19 @@
+{
+ "routeKey": "external",
+ "registryRevision": 12,
+ "toolServices": [
+ {
+ "serviceKey": "external-tools",
+ "displayName": "External Tool Server",
+ "serviceDomain": "http://tool-external.ax-hub.svc.cluster.local:8080",
+ "manifestPath": "/tool-manifest",
+ "executeBasePath": "",
+ "namePrefix": "external.",
+ "toolEndpoints": {
+ "external.exchange_rate": "/mcp/external.exchange_rate",
+ "external.weather_lookup": "/mcp/external.weather_lookup"
+ },
+ "status": "ACTIVE"
+ }
+ ]
+}
diff --git a/docs/contracts/portal-mcp/protocol-v0.1-registry.md b/docs/contracts/portal-mcp/protocol-v0.1-registry.md
new file mode 100644
index 0000000..805dd4a
--- /dev/null
+++ b/docs/contracts/portal-mcp/protocol-v0.1-registry.md
@@ -0,0 +1,227 @@
+# Portal-MCP Registry 조회 계약 v0.1
+
+- 상태: **MCP 측 구현 완료, Portal 측 미합의**
+- 기준일: 2026-08-14
+- 조회 endpoint: `GET {mcp.portal.registry-url}` — Portal이 제공
+- 구현: `PortalToolRegistryClient` (`@ConditionalOnProperty(mcp.portal.enabled=true)`)
+
+## 1. 계약 범위와 원칙
+
+Portal은 route별로 **어떤 Tool Server가 있고 그 주소가 무엇인지**를 관리한다.
+MCP는 이 목록을 주기적으로 조회해 Tool Service 매니페스트 조회 대상을 결정한다.
+
+| 원칙 | 내용 |
+|---|---|
+| Portal은 주소만 말한다 | Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유한다. Portal 응답에는 Tool 정의가 없다 |
+| MCP가 가져온다 | Portal은 제공만 한다. MCP에 push하지 않으며 MCP는 쓰기 endpoint를 열지 않는다 |
+| 응답은 전체 상태 | 증분이 없다. 응답에 없는 route는 memory에서 제거된다(§6) |
+| 조회 주기가 분리된다 | Portal registry와 Tool 매니페스트는 서로 다른 주기로 조회한다(§6) |
+| 실패는 삭제가 아니다 | 어떤 실패도 endpoint 목록이나 Tool snapshot을 비우지 않는다(§7) |
+| 요청 경로는 Portal을 모른다 | `tools/list`·`tools/call`은 in-memory snapshot만 읽는다 |
+
+`mcp.bundles`를 쓰는 구성과의 차이는 **하나뿐**이다. Tool Server 주소가 배포 YAML에서 오느냐
+Portal에서 오느냐. 주소를 확보한 다음의 매니페스트 조회·검증·병합은
+[tool-service-mcp v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md)를 그대로 재사용한다.
+
+## 2. Portal이 제공하는 endpoint
+
+| Method | Path | 용도 | MCP가 호출하는가 |
+|---|---|---|---|
+| `GET` | `/api/portal/registry` | 전체 route 집계 조회 | **예. 유일한 호출 대상** |
+| `GET` | `/api/portal/registry/{routeKey}` | 단일 route 조회 | 아니오 (§5 참고) |
+
+MCP는 `mcp.portal.registry-url`에 설정된 **하나의 URL만** 호출한다.
+route별로 나눠 호출하지 않는다. 따라서 **`registry-url`은 집계 endpoint를 가리켜야 한다.**
+
+> `registry-url`에 `{route}` placeholder를 쓸 수 있게 되어 있으나,
+> 현재 구현은 registry 갱신 시 `{route}`를 **항상 빈 문자열로** 치환한다(`registryUrl("")`).
+> 즉 `/api/portal/registry/{route}` 형태로 설정하면 `/api/portal/registry/`를 호출해 실패한다.
+> **placeholder를 쓰지 않는다.**
+
+단일 route 조회 endpoint는 Portal 화면과 운영 확인용으로 남아 있으며 MCP 경로가 아니다.
+다만 응답 shape는 MCP가 파싱할 수 있는 형태를 유지한다(§4.2). 이유는 §4.3에 있다.
+
+## 3. MCP 설정 (YAML)
+
+예시는 [mcp-portal-config.yaml](examples/registry-v0.1/mcp-portal-config.yaml)에 있다.
+
+```yaml
+mcp:
+ portal:
+ enabled: true
+ registry-url: http://portal.ax-hub.svc.cluster.local:8080/api/portal/registry
+ refresh-interval-seconds: 300
+ discovery:
+ enabled: true
+ bundles: []
+```
+
+| 항목 | 필수 | 설명 |
+|---|---|---|
+| `mcp.portal.enabled` | 예 | `true`일 때만 `PortalToolRegistryClient`가 등록된다. `false`면 `mcp.bundles`를 사용한다 |
+| `mcp.portal.registry-url` | `enabled=true`일 때 예 | 집계 조회 URL. 누락 시 기동이 실패한다(`McpProperties.isPortalTargetDeclared`) |
+| `mcp.portal.refresh-interval-seconds` | 아니오(기본 300) | Portal registry 조회 주기 |
+| `mcp.redis.portal-registry-key` | 아니오 | Portal registry fallback Redis key. 기본값은 `{key-prefix}:portal-registry` |
+
+`mcp.portal.enabled=true`이면 `mcp.bundles`는 비운다. endpoint 원천이 둘이 되지 않게 한다.
+
+> route key를 지정하는 설정은 **없다.** route key는 요청 경로에서만 결정되며(`McpRequestContextFactory`),
+> 설정 기본값으로 보정하지 않는다(§7). 과거 `mcp.portal.route-key`가 선언만 되어 있었으나
+> 어떤 코드도 읽지 않아 제거했다([ADR-0010](../../decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md)).
+
+## 4. 응답 계약
+
+### 4.1 집계 응답 (MCP가 사용하는 형태)
+
+예제: [aggregate-registry-response.json](examples/registry-v0.1/aggregate-registry-response.json)
+
+```json
+{
+ "registryRevision": 12,
+ "routes": [
+ { "routeKey": "external", "toolServices": [ /* §4.4 */ ] }
+ ]
+}
+```
+
+| 필드 | 필수 | 타입 | 의미 |
+|---|---|---|---|
+| `registryRevision` | 아니오 | number 또는 string | 변경 감지용 판. §6 |
+| `routes` | 예 | array | route 전체 목록. 이 배열이 있으면 집계 응답으로 해석한다 |
+| `routes[].routeKey` | **예** | string | 비어 있으면 registry 오류. 공백은 trim된다 |
+| `routes[].toolServices` | 예 | array | 해당 route의 Tool Server 목록. §4.4 |
+
+### 4.2 단일 route 응답
+
+예제: [route-registry-response.json](examples/registry-v0.1/route-registry-response.json)
+
+```json
+{
+ "routeKey": "external",
+ "registryRevision": 12,
+ "toolServices": [ /* §4.4 */ ]
+}
+```
+
+| 필드 | 필수 | 의미 |
+|---|---|---|
+| `routeKey` | **예** | 최상위에 있어야 한다 |
+| `toolServices` | 예 | §4.4 |
+
+### 4.3 두 형태를 모두 받는 이유와 그 위험
+
+MCP는 응답에 `routes` 배열이 **없으면** 단일 route 문서로 해석해 최상위 `routeKey`를 읽는다.
+이 관용은 Redis fallback에 저장된 과거 형태를 읽기 위한 것이다.
+
+**두 형태의 삭제 의미가 다르다.**
+
+| 응답 형태 | memory 반영 |
+|---|---|
+| 집계(`routes` 있음) | 응답에 없는 route를 **제거**한다. 전체 상태 교체 |
+| 단일(`routes` 없음) | 그 route만 **덮어쓴다**. 다른 route는 남는다 |
+
+따라서 운영에서 Portal은 **항상 집계 형태로 응답한다.** 단일 형태를 정기 조회 대상으로 쓰면
+Portal에서 삭제한 route가 MCP memory에 영원히 남는다.
+
+### 4.4 `toolServices[]` 항목
+
+| 필드 | 필수 | 기본값 | MCP가 만드는 값 |
+|---|---|---|---|
+| `serviceKey` | **예** | — | bundle id. 매니페스트의 `bundleId`와 일치해야 한다 |
+| `serviceDomain` | **예** | — | scheme+host+port. 끝 `/`는 제거된다 |
+| `manifestPath` | **예** | — | `manifestUrl = serviceDomain + manifestPath`. 앞 `/`가 없으면 붙인다 |
+| `executeBasePath` | 아니오 | `""` | `baseEndpoint = serviceDomain + executeBasePath`. 앞뒤 `/`가 정규화된다 |
+| `namePrefix` | 아니오 | `""` | Tool name 접두사 검증 기준 |
+| `toolEndpoints` | 아니오 | `{}` | Tool name → 실행 path. 값은 앞 `/`가 보장되도록 정규화된다 |
+| `status` | 아니오 | `"ACTIVE"` | `ACTIVE`가 아니면 **조용히 제외**한다. 대소문자 무시 |
+| `displayName` | 아니오 | — | Portal 화면용. **MCP는 무시한다** |
+
+- 필수 필드가 없거나 공백이면 registry 오류다. 오류 메시지에는 필드명만 남기고 응답 원문은 넣지 않는다.
+- **ACTIVE 서비스가 하나도 없으면 그 응답 전체를 실패로 처리한다.** 빈 목록으로 교체하지 않는다.
+- `toolEndpoints`가 비면 실행 주소는 `baseEndpoint`에 Tool name을 붙이는 기존 계약을 따른다.
+
+## 5. Portal이 응답에 넣지 않는 것
+
+| 넣지 않는 것 | 이유 |
+|---|---|
+| Tool 정의(name, schema, timeout) | Tool Service 매니페스트가 정본이다 |
+| 매니페스트 조회용 API key | §9의 미확정 항목. 현재 MCP는 자기 설정의 key를 쓴다 |
+| MCP 자신의 endpoint 주소 | MCP가 자기 주소를 Portal에서 받지 않는다 |
+
+## 6. 조회 주기와 변경 감지
+
+| 주기 | 대상 | 설정 |
+|---|---|---|
+| 기동 preload | Portal registry → 각 Tool Service 매니페스트 | 즉시 |
+| `mcp.portal.refresh-interval-seconds` | Portal registry만 | 기본 300초 |
+| `mcp.registry.refresh-interval-seconds` | 저장된 endpoint의 매니페스트만 | 기본 30초 |
+
+`registryRevision`이 직전과 다르면 MCP는 그 응답 전체를 INFO 로그로 남기고,
+**즉시 매니페스트 refresh를 한 번 더 트리거한다**(`ToolRegistryRefreshScheduler`의 `portal-change`).
+Portal에서 endpoint를 바꾼 뒤 매니페스트 주기를 기다리지 않게 하기 위한 것이다.
+
+`registryRevision`은 **변경 감지에만** 쓴다. 순서 비교를 하지 않으므로 값이 되돌아가도
+"변경됨"으로 처리한다. 단조 증가는 Portal이 보장할 항목이다(README 확정 항목 3).
+
+> 이 로그는 응답 JSON 전체를 출력한다. registry 응답에는 credential이 없으나
+> **내부 endpoint 주소가 그대로 남는다.** 폐쇄망 운영 로그 정책에서 확인이 필요하다.
+
+## 7. 실패 처리
+
+모든 registry 실패는 JSON-RPC `TOOL_REGISTRY_UNAVAILABLE`로 변환된다.
+
+| 상황 | 동작 |
+|---|---|
+| Portal 조회 실패 + memory에 endpoint 있음 | **memory 유지.** Redis를 읽지 않는다. WARN 로그 |
+| Portal 조회 실패 + memory 비어 있음(cold start) | `mcp.redis.portal-registry-key`의 registry JSON을 fallback으로 읽는다 |
+| Portal·Redis 모두 실패 | 실패로 처리하고 다음 주기에 재시도. 목록은 비우지 않는다 |
+| 요청 route가 memory에 없음 | `Portal registry route is not found: {routeKey}` |
+| 요청 route key가 공백 | `Portal registry routeKey is required`. **설정 기본 route로 보정하지 않는다** |
+| ACTIVE 서비스 없음 | `Portal registry has no active Tool Service` |
+| 직전 성공본조차 없는 Tool Service가 있음 | 카탈로그 전체를 교체하지 않는다 |
+| Tool name 중복 (서비스 간) | 교체하지 않는다 |
+| `mcp.discovery.max-tools-total` 초과 | 교체하지 않는다 |
+
+마지막 세 항목은 tool-service-mcp v0.2의 병합 규칙을 그대로 따른다.
+route key를 보정하지 않는 것은 **잘못된 단일 진입점 호출을 조용히 성공시키지 않기 위한 것**이다.
+
+Redis fallback은 두 종류이며 key가 분리된다.
+
+| key | 내용 | 언제 |
+|---|---|---|
+| `mcp.redis.portal-registry-key` | Portal registry 응답 JSON | route·endpoint 목록 자체를 모를 때 |
+| `{key-prefix}:{identity}:v2:route:{routeToken}` | route별 Tool snapshot | 이미 아는 route의 마지막 Tool 목록 |
+
+## 8. 보안 요구사항
+
+`mcp.bundles` 구성에서 [AGENTS.md](../../../AGENTS.md)의 불변식은
+"outbound 주소는 설정에서만 온다"이다. **Portal 구성에서는 그 원천이 Portal로 옮겨간다.**
+
+따라서 이 계약은 다음을 요구한다.
+
+1. **Portal registry API는 공개 네트워크에 노출하지 않는다.** MCP와 Portal 사이는 NetworkPolicy로 제한한다.
+2. **Portal의 쓰기 API(bundle 등록·수정)는 인증을 요구한다.** 이 API를 장악하면 MCP의 호출 대상을 바꿀 수 있다.
+3. Tool Service 매니페스트는 여전히 호출 대상을 바꾸지 못한다. 매니페스트는 `serviceDomain`을 덮어쓸 수 없다.
+
+1·2를 만족하지 못하는 환경에서는 Portal 구성을 쓰지 않고 `mcp.bundles`를 쓴다.
+
+## 9. 미확정 항목
+
+| 항목 | 현재 | 확정 필요 |
+|---|---|---|
+| Portal API 인증 | 없음 | 방식과 credential 관리 주체 |
+| 매니페스트 조회 credential | MCP 설정의 `mcp.tool-client.api-key` 단일 값 | Tool Service별로 다를 때 전달 경로. registry 응답에 담을지 여부 |
+| `registryRevision` 채번 | Portal in-memory 카운터 | 재기동 시 유지 여부, 단조 증가 보장 |
+| route 삭제 | 집계 응답에서 사라지면 즉시 제거 | 진행 중 요청에 대한 rolling 처리 |
+
+## 10. 예제와 검증
+
+| 파일 | 용도 |
+|---|---|
+| [aggregate-registry-response.json](examples/registry-v0.1/aggregate-registry-response.json) | 운영에서 MCP가 받는 형태 |
+| [route-registry-response.json](examples/registry-v0.1/route-registry-response.json) | 단일 route 형태 |
+| [mcp-portal-config.yaml](examples/registry-v0.1/mcp-portal-config.yaml) | MCP 설정 예시 |
+
+앞의 두 JSON은 `PortalRegistryContractExampleTest`가 읽어 `PortalToolRegistryClient`의
+실제 파싱 경로에 태운다. `serviceDomain`만 테스트가 MockWebServer 주소로 치환하며,
+나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**
diff --git a/docs/decisions/ADR-0007-one-mcp-per-tool-service.md b/docs/decisions/ADR-0007-one-mcp-per-tool-service.md
index ac54cc9..b419ea9 100644
--- a/docs/decisions/ADR-0007-one-mcp-per-tool-service.md
+++ b/docs/decisions/ADR-0007-one-mcp-per-tool-service.md
@@ -1,9 +1,14 @@
# ADR-0007 MCP 배포 하나는 Tool Service 하나만 본다
-- 상태: Accepted
+- 상태: Superseded
- 결정일: 2026-08-02
+- 대체 결정: [ADR-0010](ADR-0010-portal-owns-route-and-endpoint-registry.md)
- 관련: [ADR-0001](ADR-0001-stateless-execution-boundary.md), [ADR-0002](ADR-0002-tool-exposure-and-single-call.md), [ADR-0009](ADR-0009-container-handles-public-mcp-path.md), [계약 v0.2](../contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md)
+> 이 문서는 당시 검토 이력을 보존한다. 내부망 운영은 endpoint 목록과 route 매핑의 원천을 Portal로 옮겼으므로
+> 현재 구현과 신규 연동에는 [ADR-0010](ADR-0010-portal-owns-route-and-endpoint-registry.md)을 적용한다.
+> 아래 격리 논거는 폐기된 것이 아니라 ADR-0010이 무엇을 포기했는지 판단하는 근거로 남는다.
+
외부에서 여러 MCP를 하나의 host 아래 path로 묶는 방식은 [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)이
소유한다. OpenShift Route가 원래 path를 유지한 채 각각의 독립 배포로 연결하므로 이 ADR의 1:1 결정은 그대로 유지된다.
diff --git a/docs/decisions/ADR-0009-container-handles-public-mcp-path.md b/docs/decisions/ADR-0009-container-handles-public-mcp-path.md
index da305c3..beb9407 100644
--- a/docs/decisions/ADR-0009-container-handles-public-mcp-path.md
+++ b/docs/decisions/ADR-0009-container-handles-public-mcp-path.md
@@ -3,6 +3,7 @@
- 상태: Accepted
- 결정일: 2026-08-05
- 대체: [ADR-0008](ADR-0008-shared-host-path-routing.md)
+- 부분 대체됨: 결정 4는 [ADR-0010](ADR-0010-portal-owns-route-and-endpoint-registry.md)이 대체한다
- 관련: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
## 배경
diff --git a/docs/decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md b/docs/decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md
new file mode 100644
index 0000000..b18b4bb
--- /dev/null
+++ b/docs/decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md
@@ -0,0 +1,136 @@
+# ADR-0010 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다
+
+- 상태: Accepted
+- 결정일: 2026-08-16
+- 대체 결정: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) 전체, [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) 결정 4
+- 관련: [ADR-0001](ADR-0001-stateless-execution-boundary.md) · [ADR-0005](ADR-0005-standard-tool-name.md) · [Portal-MCP 계약 v0.1](../contracts/portal-mcp/protocol-v0.1-registry.md)
+
+## 배경
+
+[ADR-0007](ADR-0007-one-mcp-per-tool-service.md)은 MCP 배포 하나가 Tool Service 하나만 보게 하고 `mcp.bundles`를 배포 설정에 선언했다.
+그 전제는 **어떤 Tool Service를 볼지가 배포 시점에 확정된다**는 것이었다.
+
+내부망 운영은 그 전제를 따르지 않기로 했다. route와 Tool Service의 매핑은 Portal이 관리하고,
+MCP는 기동할 때 Portal API에서 route 정보·Tool Service endpoint·매핑 관계를 받아 온다.
+매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.
+
+## 결정
+
+1. **Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다.** MCP는 기동 preload와 주기 refresh에서 Portal registry API를 조회한다. `mcp.bundles`는 비운다.
+2. **MCP 배포 하나가 N개 route를 서비스한다.** route key는 `/mcp/{routeKey}` URI에서 결정하며 설정 기본값으로 보정하지 않는다.
+3. **route 하나에 N개 Tool Service가 붙을 수 있다.** 카탈로그 병합 단위는 route다.
+4. Portal은 **주소만** 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유한다.
+5. 요청 경로(`tools/list`, `tools/call`)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다.
+6. 요청·응답 모양과 실패 처리는 [Portal-MCP 계약 v0.1](../contracts/portal-mcp/protocol-v0.1-registry.md)이 정본이다.
+7. `deploy/helm/`의 배포별 topology는 내부망 운영에 사용하지 않는다.
+
+## 근거
+
+### 매핑이 동적이면 배포 축과 매핑 축을 겹칠 수 없다
+
+ADR-0007은 매핑을 배포 정의에 넣었다. Portal이 매핑을 소유하는 순간 **매핑 변경이 곧 배포 변경**이 되어
+Portal을 원천으로 둔 의미가 사라진다. 원천이 Portal이면 배포는 매핑에 대해 중립이어야 하고,
+그래서 한 배포가 N route를 서비스한다.
+
+### 이 결정은 새 코드를 요구하지 않는다
+
+구현은 이미 이 구조다.
+
+- `PortalToolRegistryClient`가 registry 응답을 `bundlesByRoute`(route → Tool Service 목록)로 만든다. route당 N개를 이미 지원한다
+- `McpRequestContextFactory`가 `/mcp/{route}`에서 route key를 뽑는다
+- `ToolRegistryService`가 `snapshotsByRoute`로 route별 snapshot을 유지한다
+
+**확정하는 것은 코드가 아니라 어느 경로를 운영으로 삼을지다.** 지금까지 이 경로에는 근거 문서가 없었다.
+
+### ADR-0007의 격리 논거는 층위별로 다르게 남는다
+
+격리는 약해진다. 숨기지 않고 적는다.
+
+| 층위 | 격리 | 근거 |
+|---|---|---|
+| route 간 snapshot·Redis key·refresh | **유지** | `snapshotsByRoute`와 route별 Redis key로 분리 |
+| route 안 N개 Tool Service의 조회 | **유지** | bundle마다 last-good을 따로 보관하므로 한쪽 실패가 다른 쪽 조회를 멈추지 않는다 |
+| route 안 카탈로그 교체 | **없음** | 한 번도 성공하지 못한 Tool Service가 있으면 그 route 전체 교체를 거부한다 |
+| 프로세스 자원(connection pool, thread, heap) | **없음** | 전 route가 공유한다 |
+| 배포·재기동·프로세스 장애 | **없음** | 전 route가 동시에 영향을 받는다 |
+
+ADR-0007이 지키려던 **가용성 등급별 물리 분리는 이 구조에서 성립하지 않는다.**
+등급 요구가 다시 생기면 이 ADR을 재검토한다(전제 2).
+
+## 전제
+
+아래가 깨지면 이 결정을 재검토한다.
+
+1. route↔Tool Service 매핑의 관리 주체는 Portal이며, 매핑 변경이 MCP 재배포 없이 반영되어야 한다.
+2. 가용성 등급별 물리 분리 요구가 없다.
+3. 전 route의 Tool 총량과 매니페스트 조회 부하를 한 프로세스가 감당한다.
+4. Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다([계약 §8](../contracts/portal-mcp/protocol-v0.1-registry.md#8-보안-요구사항)).
+
+## 영향
+
+**실패 전파 범위를 route 단위로 잠갔다.** [계약 v0.2 §1](../contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md)의
+"aggregate는 전부 아니면 전무"는 **카탈로그 하나**를 온전히 유지하기 위한 규칙이다. 1:1 구조에서는 카탈로그
+하나가 곧 route 하나였으므로 범위가 같았다. route가 N개가 되면서 같은 코드가 "전 route 전부 아니면 전무"로
+확대됐고, 이는 의도된 것이 아니었다. 이 결정과 함께 다음을 적용한다.
+
+1. `PortalToolRegistryClient.fetchAllTools()`는 route마다 예외를 격리하고 실패한 route만 결과에서 제외한다.
+2. `ToolRegistryClient.knownRoutes()`가 원천이 선언한 route 집합을 제공하고,
+ `ToolRegistryService.refreshKnownRoutes()`는 **제거 판단을 이 집합으로만** 한다.
+ 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라져
+ [AGENTS.md](../../AGENTS.md) 2절의 "어떤 실패도 목록을 비우지 않는다"를 깨뜨린다.
+
+그 결과 Tool Service 하나가 죽어도 다른 route는 적재·갱신되고, 실패한 route는 마지막 성공본을 유지한다.
+
+**readiness는 route 하나만 준비돼도 UP이다.** readiness는 Pod 전체의 트래픽 게이트여서 route별 상태를
+표현할 수 없다. 모든 route를 요구하면 Tool Service 하나의 장애가 정상 route까지 트래픽에서 제외해
+위 격리를 되돌리는 셈이 된다. 대신 `ToolCatalogHealthIndicator`가 `readyRoutes`와
+`routesWithoutSnapshot`을 detail로 노출해 관제가 부분 상태를 감지하도록 한다.
+
+`ToolRegistryService.warmStartFromSharedCache()`는 route `""`의 Redis key만 읽으므로
+route가 이름을 갖는 이 구성에서는 동작하지 않는다. 기동 직후 빈 목록 구간을 줄이는 warm start가 없다.
+
+그 밖에:
+
+- route 없는 `/mcp` 호출은 `route key is required`로 거부된다. Agent Builder에는 route별 URL만 등록한다.
+- Tool 이름 유일성은 **route 안에서만** 검사한다. 서로 다른 route에 같은 이름이 있어도 거부하지 않는다.
+- `mcp.discovery.max-tools-total`은 전역이 아니라 **route 단위 상한**으로 동작한다.
+- Portal 조회 실패는 목록을 비우지 않는다. memory를 유지하고, cold start일 때만 Redis fallback을 읽는다.
+- `deploy/helm/`, `HelmDeploymentContractTest`, `values.yaml`의 `deployments`는 이 결정과 맞지 않는다. 상태 표시나 제거를 판단해야 한다.
+- [ADR-0002](ADR-0002-tool-exposure-and-single-call.md)의 Tool 노출 상한 50개는 Agent 기준 합계이므로 바뀌지 않는다.
+- [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정 `publicPath` 대신 `/mcp` + 동적 route로 처리한다.
+
+## 후속 조치
+
+이 ADR과 함께 정리한 항목이다. 남은 판단이 있는 것만 적는다.
+
+1. **warm start를 route별로 확장했다.** `warmStartFromSharedCache()`가 원천이 선언한 route마다
+ Redis last-good을 읽는다. 읽을 key를 알려면 route 목록이 먼저 있어야 하므로 기동 preload 순서를
+ `registry 조회 → warm start → manifest 조회`로 바꿨다.
+2. **`mcp.portal.route-key`를 제거했다.** 어떤 코드도 읽지 않았고, route key는 요청 URI에서만 결정된다.
+ 설정으로 기본 route를 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.
+3. **Helm chart는 유지하되 적용 범위를 명시했다.** `mcp.bundles` 구성이 코드에 그대로 남아 있고 local
+ 검증과 1:1 배포 환경에서 유효하므로 삭제하지 않는다. 내부망 운영 대상이 아니라는 사실을
+ `deploy/README.md`와 `values.yaml` 머리말에 적었다. `HelmDeploymentContractTest`는 그 구성의
+ 계약으로 계속 유효하다.
+4. **`ToolRegistryService.java`의 한글 Javadoc 손상을 복구했다.** 이중 인코딩으로 33줄이 깨져 있었고
+ 무손실 복원이 불가능해 코드 동작에 맞춰 다시 썼다. `awaitRefresh`의 Javadoc이 `replaceSnapshot` 위에
+ 겹쳐 있던 고아 블록도 제거했다. 이 결정과 무관한 기존 결함이었다.
+
+남은 판단:
+
+- readiness를 route 단위로 세분화할 필요가 생기는지는 운영 관측 이후에 다시 본다. 현재는 최소 1개 route로
+ UP을 판정하고 `routesWithoutSnapshot`을 detail로 노출한다(위 영향 절).
+
+## 채택하지 않은 대안
+
+**ADR-0007을 유지하고 배포마다 Portal의 자기 route만 조회한다.**
+격리는 지키지만 route 추가가 배포 추가가 된다. Portal이 route 목록의 원천인데 배포 topology가 그 목록을 따라가야 하므로 순환이 생긴다.
+
+**`mcp.bundles`에 매핑을 하드코딩한다.**
+매핑 변경마다 재배포가 필요해 전제 1과 충돌한다. 또한 비Portal 경로의 `ToolBundleRegistryClient.fetchTools(routeKey)`는
+**routeKey를 읽지 않으므로** route마다 다른 카탈로그를 만들 수 없다. 모든 route가 같은 목록을 오류 없이 반환해
+라우팅이 검증되지 않은 채 통과한다.
+
+**route별로 프로세스를 나누고 각자 Portal을 조회한다.**
+자원 격리는 얻지만 Portal이 route 목록을 소유하는 이상 배포 수를 Portal이 정하게 된다.
+운영 중 route 추가가 배포 파이프라인을 건드린다.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 4244e76..e77fc98 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -18,6 +18,7 @@
| [ADR-0004](ADR-0004-execution-guardrails.md) | 300초, Raw Data, unsafe retry 실행 가드레일 | Accepted |
| [ADR-0005](ADR-0005-standard-tool-name.md) | 표준 MCP Tool name을 실행 식별자로 사용 | Accepted |
| [ADR-0006](ADR-0006-no-authentication-in-mcp.md) | MCP Server는 인증·인가를 하지 않는다 | Accepted |
-| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Accepted |
+| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Superseded |
| [ADR-0008](ADR-0008-shared-host-path-routing.md) | 공유 host의 path를 독립 MCP 배포로 연결 | Superseded |
| [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) | 컨테이너가 공개 MCP path를 직접 처리 | Accepted |
+| [ADR-0010](ADR-0010-portal-owns-route-and-endpoint-registry.md) | Tool Server endpoint 목록과 route 매핑의 원천은 Portal | Accepted |
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java b/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java
index f25df00..89f550c 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/config/McpProperties.java
@@ -41,7 +41,7 @@ public record McpProperties(
*/
public McpProperties {
bundles = bundles == null ? List.of() : List.copyOf(bundles);
- portal = portal == null ? new Portal(false, "", "", 300) : portal;
+ portal = portal == null ? new Portal(false, "", 300) : portal;
}
/**
@@ -165,7 +165,7 @@ public record McpProperties(
* 포털이 소유한 Tool Service registry 조회 설정입니다.
* MCP 요청을 직접 처리하지 않고 배경 refresh가 route별 Tool Service 위치와 revision을 읽을 때 사용합니다.
*/
- public record Portal(boolean enabled, String routeKey, String registryUrl, @Min(1) long refreshIntervalSeconds) {
+ public record Portal(boolean enabled, String registryUrl, @Min(1) long refreshIntervalSeconds) {
}
/**
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java
index 481e05d..885797b 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/observability/ToolCatalogHealthIndicator.java
@@ -2,6 +2,8 @@ package io.shinhanlife.dap.biz.mcp.observability;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
+import java.util.List;
+import java.util.Set;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@@ -28,16 +30,28 @@ public class ToolCatalogHealthIndicator implements HealthIndicator {
/**
* 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다.
+ *
+ *
한 배포가 여러 route를 서비스하는 구성에서는 route 하나만 준비돼도 UP입니다.
+ * readiness는 Pod 전체의 트래픽 게이트여서 route별 상태를 표현할 수 없고, 모든 route를 요구하면
+ * Tool Service 하나의 장애가 정상 route까지 트래픽에서 제외해 장애 범위를 오히려 넓히기 때문입니다.
+ * 대신 준비되지 않은 route를 detail로 노출해 관제가 부분 상태를 감지하게 합니다.
*/
@Override
public Health health() {
boolean firstAttemptCompleted = scheduler.firstAttemptCompleted();
boolean usableSnapshot = registryService.hasUsableSnapshot();
+ Set readyRoutes = registryService.readyRouteKeys();
+ List pendingRoutes = registryService.knownRouteKeys().stream()
+ .filter(routeKey -> !readyRoutes.contains(routeKey))
+ .sorted()
+ .toList();
Health.Builder health = firstAttemptCompleted && usableSnapshot ? Health.up() : Health.down();
return health.withDetail(
"firstDiscoveryAttempt",
firstAttemptCompleted ? "completed" : "pending")
.withDetail("usableSnapshot", usableSnapshot)
+ .withDetail("readyRoutes", readyRoutes.stream().sorted().toList())
+ .withDetail("routesWithoutSnapshot", pendingRoutes)
.build();
}
}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java
index 1b416e0..2374a29 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClient.java
@@ -74,15 +74,39 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
/**
* 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다.
* 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다.
+ *
+ * 한 route의 조회 실패는 그 route만 결과에서 빠뜨리고 나머지 route의 조회를 계속합니다.
+ * "aggregate는 전부 아니면 전무"는 카탈로그 하나를 온전하게 유지하기 위한 규칙이므로 route 안에서만 적용해야 하며,
+ * 여기서 예외를 그대로 올리면 Tool Service 하나의 장애가 전 route의 갱신을 멈춰 서로 다른 업무가 서로를 막습니다.
+ * 빠진 route의 기존 snapshot을 지울지는 호출자가 {@link #knownRoutes()}로 판단합니다.
*/
@Override
public Map> fetchAllTools() {
ensurePortalRegistryLoaded();
Map> snapshots = new LinkedHashMap<>();
- bundlesByRoute.forEach((routeKey, bundles) -> snapshots.put(routeKey, fetchRouteTools(routeKey, bundles)));
+ bundlesByRoute.forEach((routeKey, bundles) -> {
+ try {
+ snapshots.put(routeKey, fetchRouteTools(routeKey, bundles));
+ } catch (RuntimeException exception) {
+ log.warn(
+ "Portal route catalog refresh failed; other routes continue. routeKey={} reason={} message={}",
+ routeKey,
+ exception.getClass().getSimpleName(),
+ exception.getMessage());
+ }
+ });
return Map.copyOf(snapshots);
}
+ /**
+ * 포털 registry가 선언한 route key 전체를 반환합니다.
+ * 조회 성공 여부와 무관하며, 포털 응답에서 사라진 route만 이 집합에서 빠집니다.
+ */
+ @Override
+ public Set knownRoutes() {
+ return Set.copyOf(bundlesByRoute.keySet());
+ }
+
/**
* 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 memory에 갱신합니다.
* manifest 조회는 수행하지 않으며, 실패하면 기존 endpoint 목록이나 Redis fallback 규칙을 호출자에게 전달합니다.
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java
index 3bf1578..2ceb158 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryClient.java
@@ -2,6 +2,7 @@ package io.shinhanlife.dap.biz.mcp.registry;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* Tool metadata의 원천(source)을 읽는 역할입니다.
@@ -35,6 +36,16 @@ public interface ToolRegistryClient {
return false;
}
+ /**
+ * 원천이 현재 알고 있는 route key 전체를 반환합니다.
+ * {@link #fetchAllTools()}가 조회에 성공한 route만 담는 것과 달리, 이 집합은 조회 성공 여부와 무관하게 원천이 선언한 route를 뜻합니다.
+ * 호출자는 이 둘의 차이로 "이번에 조회가 실패한 route"와 "원천에서 사라진 route"를 구분하며, 전자의 기존 snapshot을 지우지 않습니다.
+ * route 개념이 없는 구현은 빈 집합을 반환하고, 호출자는 기존 제거 규칙을 그대로 사용합니다.
+ */
+ default Set knownRoutes() {
+ return Set.of();
+ }
+
/**
* route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다.
*/
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java
index ae1a55b..71a7e5e 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshScheduler.java
@@ -28,13 +28,14 @@ public class ToolRegistryRefreshScheduler {
}
/**
- * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신
- * 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다.
+ * 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다.
+ * 먼저 원천 registry에서 route 목록을 확보하고, 다른 replica가 공유 cache에 남긴 route별 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄인 뒤, 원천을 조회해 최신 상태로 교체합니다.
+ * route 목록을 모르면 어느 Redis key를 읽어야 할지 알 수 없으므로 registry 조회가 warm start보다 먼저 와야 하며, 세 단계가 모두 실패해도 애플리케이션은 계속 기동합니다.
*/
@EventListener(ApplicationReadyEvent.class)
public void preload() {
- safeWarmStart();
safePortalRefresh("preload");
+ safeWarmStart();
safeManifestRefresh("preload");
firstAttemptCompleted = true;
}
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java
index 53fff88..36c341c 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryService.java
@@ -9,6 +9,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
@@ -20,7 +21,8 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
/**
- * Tool Registry metadata 議고쉶???⑥씪 吏꾩엯?먯씠硫??붿껌 寃쎈줈? 諛곌꼍 媛깆떊 寃쎈줈瑜?遺꾨━?섎뒗 ?쒕퉬?ㅼ엯?덈떎. {@code tools/list}? {@code tools/call}???붿껌 寃쎈줈??in-memory snapshot留??쎌쑝誘濡?Redis ?μ븷??吏?곗씠 ?묐떟?? * ?곹뼢??二쇱? ?딆뒿?덈떎. Redis??諛곌꼍 媛깆떊怨?warm start?먯꽌留??ъ슜?섎뒗 replica 媛?怨듭쑀 吏?먯씠硫? ?먯쿇 議고쉶 ?깃났 寃곌낵留???ν빀?덈떎. 二쇱슂 ?섏〈?깆? ?먯쿇 port {@link ToolRegistryClient}? ?좏깮??Redis cache?낅땲??
+ * Tool Registry metadata 조회의 단일 진입점이며 요청 경로와 배경 갱신 경로를 분리하는 서비스입니다. {@code tools/list}와 {@code tools/call}의 요청 경로는 in-memory snapshot만 읽으므로 Redis 장애나 지연이 응답에
+ * 영향을 주지 않습니다. Redis는 배경 갱신과 warm start에서만 쓰는 replica 간 공유 지점이며 원천 조회에 성공한 결과만 저장합니다. 주요 의존성은 원천 port {@link ToolRegistryClient}와 선택적 Redis cache입니다.
*/
@Service
public class ToolRegistryService {
@@ -36,7 +38,7 @@ public class ToolRegistryService {
new ConcurrentHashMap<>();
/**
- * ?먯쿇 Registry? memory쨌?좏깮??Redis 怨듭쑀 cache瑜?二쇱엯諛쏆뒿?덈떎.
+ * 원천 Registry와 memory·선택적 Redis 공유 cache를 주입받습니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient, Optional redisCache) {
@@ -45,8 +47,8 @@ public class ToolRegistryService {
}
/**
- * ?먯쿇 Registry, ?좏깮??Redis 怨듭쑀 cache, Tool 紐⑸줉 蹂寃??대깽??諛쒗뻾?먮? 二쇱엯諛쏆뒿?덈떎.
- * Spring 湲곕룞 ???몄텧?섎ʼn, refresh ?깃났?쇰줈 湲곗〈 route snapshot???щ씪吏??뚮쭔 ?대깽?몃? 諛쒗뻾?⑸땲??
+ * 원천 Registry, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자를 주입받습니다.
+ * Spring 기동 시 호출되며, refresh 성공으로 기존 route snapshot이 달라졌을 때만 이벤트를 발행합니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient,
@@ -56,8 +58,8 @@ public class ToolRegistryService {
}
/**
- * ?먯쿇 Registry, ?좏깮??Redis cache, 蹂寃??대깽??諛쒗뻾?? JSON 吏곷젹???꾧뎄瑜?二쇱엯諛쏆뒿?덈떎.
- * Spring 湲곕룞 ???몄텧?섎ʼn snapshot 蹂寃?寃利?濡쒓렇瑜?JSON ?뺥깭濡??④만 ???덇쾶 ObjectMapper瑜?蹂닿??⑸땲??
+ * 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, JSON 직렬화 도구를 주입받습니다.
+ * Spring 기동 시 호출되며 snapshot 변경 검증 로그를 JSON 형태로 남길 수 있게 ObjectMapper를 보관합니다.
*/
@Autowired
public ToolRegistryService(
@@ -72,15 +74,16 @@ public class ToolRegistryService {
}
/**
- * ?쒖꽦 Tool 紐⑸줉??in-memory snapshot?먯꽌 ?쎌뒿?덈떎. ?붿껌 寃쎈줈?먯꽌??Redis瑜??몄텧?섏? ?딆쑝誘濡?Redis ?μ븷??吏?곗씠 {@code tools/list} ?묐떟 ?쒓컙???곹뼢??二쇱? ?딆뒿?덈떎. snapshot???꾩쭅 鍮꾩뼱 ?덈뒗 湲곕룞 吏곹썑?먮쭔 ?먯쿇????踰? * 議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
+ * 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으므로 Redis 장애나 지연이 {@code tools/list} 응답 시간에 영향을 주지 않습니다. snapshot이 아직 비어 있는 기동 직후에만 원천을 한 번
+ * 조회해 cold start 공백을 메웁니다.
*/
public List listTools() {
return listTools("");
}
/**
- * route蹂?in-memory snapshot?먯꽌 ?쒖꽦 Tool 紐⑸줉???쎌뒿?덈떎.
- * ?붿껌 route??snapshot???놁쑝硫??대떦 route??Registry ?먯쿇????踰?議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
+ * route별 in-memory snapshot에서 활성 Tool 목록을 읽습니다.
+ * 요청 route의 snapshot이 없으면 해당 route만 Registry 원천에서 한 번 조회해 cold start 공백을 메웁니다.
*/
public List listTools(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -92,34 +95,54 @@ public class ToolRegistryService {
}
/**
- * ?붿껌??泥섎━?????덈뒗 Tool snapshot??memory???곸옱?먮뒗吏 諛섑솚?⑸땲?? ?먯쿇 ?먮뒗 Redis?먯꽌 ?깃났?곸쑝濡?梨꾪깮??鍮?紐⑸줉???좏슚???꾩껜 ?곹깭?대?濡?{@code null} ?щ?留??먮떒?섎ʼn, readiness ?뺤씤 怨쇱젙?먯꽌 Redis??Tool Service瑜? * ?몄텧?섏? ?딆뒿?덈떎.
+ * 요청을 처리할 수 있는 Tool snapshot이 memory에 존재하는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 값이 목록에 유효한 전체 상태이므로 {@code null} 여부만으로 판단하며, readiness 확인 과정에서 Redis나 Tool Service를
+ * 호출하지 않습니다.
*/
public boolean hasUsableSnapshot() {
return !snapshotsByRoute.isEmpty();
}
/**
- * 湲곕룞 吏곹썑 ?ㅻⅨ replica媛 怨듭쑀 吏?먯뿉 ??ν빐 ??snapshot??癒쇱? ?곸옱?⑸땲?? 泥??먯쿇 議고쉶媛 ?앸굹湲??꾩쓽 鍮?紐⑸줉 援ш컙??以꾩씠湲??꾪븳 best-effort ?숈옉?대ʼn, ?ㅽ뙣?섍굅??媛믪씠 ?놁쑝硫??꾨Т寃껊룄 ?섏? ?딆뒿?덈떎.
+ * 현재 in-memory snapshot을 확보한 route key 집합을 반환합니다.
+ * 한 배포가 여러 route를 서비스하는 구성에서는 일부 route만 준비된 상태가 정상적으로 발생하므로, readiness 판정이 아니라 그 부분 상태를 관측하는 데 사용합니다.
+ */
+ public Set readyRouteKeys() {
+ return Set.copyOf(snapshotsByRoute.keySet());
+ }
+
+ /**
+ * 원천이 선언한 route key 집합을 그대로 전달합니다.
+ * {@link #readyRouteKeys()}와의 차이가 곧 "원천은 알고 있으나 아직 Tool 목록을 확보하지 못한 route"이며, 관제는 이 차이로 부분 장애를 감지합니다.
+ */
+ public Set knownRouteKeys() {
+ return registryClient.knownRoutes();
+ }
+
+ /**
+ * 기동 직후 다른 replica가 공유 지점에 저장해 둔 snapshot을 먼저 적재합니다. 첫 원천 조회가 끝나기 전의 빈 목록 구간을 줄이기 위한 best-effort 동작이며, 실패하거나 값이 없으면 아무것도 하지 않습니다.
+ * 어느 Redis key를 읽을지는 원천이 선언한 route 목록이 정하므로, route를 모르는 시점에 호출하면 기존 단일 route key만 시도합니다.
*/
public void warmStartFromSharedCache() {
if (!snapshotsByRoute.isEmpty()) {
return;
}
- redisCache
- .flatMap(cache -> cache.loadSnapshot(""))
- .ifPresent(tools -> snapshotsByRoute.putIfAbsent("", List.copyOf(tools)));
+ Set declaredRoutes = registryClient.knownRoutes();
+ Set routeKeys = declaredRoutes.isEmpty() ? Set.of("") : declaredRoutes;
+ routeKeys.forEach(routeKey -> redisCache
+ .flatMap(cache -> cache.loadSnapshot(routeKey))
+ .ifPresent(tools -> snapshotsByRoute.putIfAbsent(routeKey, List.copyOf(tools))));
}
/**
- * ?쒖? Tool ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎. cache媛 ?ㅻ옒?먯쓣 ???덉쑝誘濡?泥?議고쉶?먯꽌 紐?李얠쑝硫?Registry瑜???踰?refresh????理쒖쥌 ?먮떒?⑸땲??
+ * 표준 Tool 이름과 일치하는 활성 Tool 하나를 찾습니다. cache가 오래됐을 수 있으므로 첫 조회에서 못 찾으면 Registry를 한 번 refresh한 뒤 최종 판단합니다.
*/
public ToolMetadata findEnabledTool(String name) {
return findEnabledTool("", name);
}
/**
- * ?붿껌 route??Tool snapshot?먯꽌 ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎.
- * route蹂?cache媛 ?ㅻ옒?섏뿀?????덉쑝誘濡?理쒖큹 miss ???대떦 route留?refresh????理쒖쥌 ?먮떒?⑸땲??
+ * 요청 route의 Tool snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다.
+ * route별 cache가 오래됐을 수 있으므로 최초 miss 시 해당 route만 refresh한 뒤 최종 판단합니다.
*/
public ToolMetadata findEnabledTool(String routeKey, String name) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -143,15 +166,16 @@ public class ToolRegistryService {
}
/**
- * Registry ?먯쿇??吏곸젒 ?쎌뼱 ?쒖꽦 Tool snapshot??媛깆떊?⑸땲?? 議고쉶???깃났?덉쓣 ?뚮쭔 snapshot??援먯껜?섍퀬 怨듭쑀 cache????ν븯誘濡? ?ㅽ뙣媛 湲곗〈 紐⑸줉??鍮꾩슦嫄곕굹 ?ㅻⅨ replica媛 ??ν븳 ?뺤긽 snapshot????뼱?곗? ?딆뒿?덈떎. memory瑜? * 癒쇱? 媛깆떊??Redis ?μ븷? 臾닿??섍쾶 理쒖떊 ?곹깭瑜??좎??⑸땲?? ?먯쿇 議고쉶媛 ?ㅽ뙣?섎㈃ 湲곗〈 memory瑜??좎??섍퀬, memory媛 鍮꾩뼱 ?덉쓣 ?뚮쭔 怨듭쑀 cache瑜?梨꾪깮?⑸땲??
+ * Registry 원천을 직접 읽어 활성 Tool snapshot을 갱신합니다. 조회에 성공했을 때만 snapshot을 교체하고 공유 cache에 저장하므로, 실패가 기존 목록을 비우거나 다른 replica가 저장한 정상 snapshot을 덮어쓰지 않습니다. memory를
+ * 먼저 갱신해 Redis 장애와 무관하게 최신 상태를 유지합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 공유 cache를 채택합니다.
*/
public List refresh() {
return refresh("");
}
/**
- * 吏?뺥븳 route??Registry ?먯쿇??吏곸젒 ?쎌뼱 route蹂?snapshot??媛깆떊?⑸땲??
- * 媛숈? route???숈떆 refresh??single-flight濡?臾띔퀬, ?ㅻⅨ route???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲??
+ * 지정한 route의 Registry 원천을 직접 읽어 route별 snapshot을 갱신합니다.
+ * 같은 route의 동시 refresh는 single-flight로 묶고, 다른 route는 서로 독립적으로 갱신합니다.
*/
public List refresh(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -174,11 +198,19 @@ public class ToolRegistryService {
}
/**
- * ?꾩옱 memory???뚮젮吏?紐⑤뱺 route瑜?二쇨린?곸쑝濡?媛깆떊?⑸땲??
- * ?꾩쭅 route ?붿껌???놁쑝硫?湲곗〈 湲곕낯 route留?媛깆떊??湲곗〈 ?⑥씪 route ?숈옉???좎??⑸땲??
+ * 원천이 알고 있는 모든 route를 주기적으로 갱신합니다.
+ * 원천이 route 목록을 제공하면 제거 판단을 그 목록으로만 하고, route 개념이 없는 원천에서는 기존 기본 route만 갱신해 기존 단일 route 동작을 유지합니다.
*/
public void refreshKnownRoutes() {
Map> snapshots = registryClient.fetchAllTools();
+ Set knownRoutes = registryClient.knownRoutes();
+ if (!knownRoutes.isEmpty()) {
+ // 원천이 route 목록을 스스로 알고 있으면 제거 판단은 그 목록만 따른다.
+ // 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라진다.
+ snapshotsByRoute.keySet().removeIf(routeKey -> !knownRoutes.contains(routeKey));
+ snapshots.forEach(this::replaceSnapshot);
+ return;
+ }
if (!snapshots.isEmpty()) {
snapshotsByRoute.keySet().removeIf(routeKey -> !snapshots.containsKey(routeKey));
snapshots.forEach(this::replaceSnapshot);
@@ -191,15 +223,15 @@ public class ToolRegistryService {
}
/**
- * ?ы꽭泥섎읆 蹂꾨룄 registry瑜?媛吏??먯쿇??endpoint 紐⑸줉留?媛깆떊?⑸땲??
- * Tool manifest 議고쉶? memory snapshot 援먯껜???섑뻾?섏? ?딆쑝硫? scheduler媛 ?ы꽭 ?꾩슜 二쇨린?먯꽌 ?몄텧?⑸땲??
+ * 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다.
+ * Tool manifest 조회와 memory snapshot 교체는 수행하지 않으며, scheduler가 포털 전용 주기에서 호출합니다.
*/
public boolean refreshSourceRegistry() {
return registryClient.refreshSourceRegistry();
}
/**
- * Tool ?먯쿇????踰?議고쉶?섍퀬 ?깃났???꾩껜 snapshot留?memory? Redis??諛섏쁺?⑸땲?? ?먯쿇 ?ㅽ뙣 ??湲곗〈 memory瑜?理쒖슦?좎쑝濡??좎??섍퀬, memory媛 鍮꾩뼱 ?덉쓣 ?뚮쭔 Redis last-good??梨꾪깮?⑸땲??
+ * Tool 원천을 한 번 조회하고 성공한 전체 snapshot만 memory와 Redis에 반영합니다. 원천 실패 시 기존 memory를 최우선으로 유지하고, memory가 비어 있을 때만 Redis last-good을 채택합니다.
*/
private List refreshOnce(String routeKey) {
try {
@@ -227,11 +259,8 @@ public class ToolRegistryService {
}
/**
- * ?ㅻⅨ ?몄텧???쒖옉??refresh 寃곌낵瑜?湲곕떎由щʼn ?먮옒 RuntimeException ?좏삎??蹂댁〈?⑸땲?? ?щ윭 cache miss媛 ?숈떆??諛쒖깮?대룄 紐⑤뱺 ?몄텧?먭? 媛숈? source fetch 寃곌낵瑜??ъ슜?⑸땲??
- */
- /**
- * ?꾩껜 registry snapshot 議고쉶 寃곌낵瑜?route蹂?memory snapshot??諛섏쁺?⑸땲??
- * ?먯쿇 議고쉶媛 ?대? ?깃났??紐⑸줉留??ㅼ뼱?ㅻ?濡??붿껌 寃쎈줈? Redis 寃쎈줈瑜?嫄대뱶由ъ? ?딄퀬, 湲곗〈 snapshot怨?鍮꾧탳??濡쒓렇? 蹂寃??대깽?몃쭔 泥섎━?⑸땲??
+ * 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다.
+ * 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로와 Redis 경로를 건드리지 않고, 기존 snapshot과 비교한 로그와 변경 이벤트만 처리합니다.
*/
private void replaceSnapshot(String routeKey, List tools) {
List immutableTools = List.copyOf(tools);
@@ -241,6 +270,11 @@ public class ToolRegistryService {
redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools));
}
+ /**
+ * 다른 호출이 이미 시작한 refresh의 결과를 기다리며 원래 RuntimeException 유형을 그대로 보존합니다.
+ * 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 원천 조회 한 번의 결과를 공유하도록 합니다.
+ * {@link CompletionException}으로 감싸인 원인을 풀어 상위 JSON-RPC 오류 변환이 원래 예외를 보게 합니다.
+ */
private List awaitRefresh(CompletableFuture> refresh) {
try {
return refresh.join();
@@ -253,7 +287,7 @@ public class ToolRegistryService {
}
/**
- * ?대쫫 議곌굔?쇰줈 ?쒖꽦 Tool ?꾨낫瑜?李얠뒿?덈떎. ?대쫫 以묐났? ?먯쿇 snapshot 蹂묓빀 ?④퀎?먯꽌 嫄곕??⑸땲??
+ * 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부합니다.
*/
private Optional match(List tools, String name) {
return tools.stream()
@@ -263,7 +297,7 @@ public class ToolRegistryService {
}
/**
- * 李얠? 紐삵븳 Tool ?대쫫???ы븿??Tool not found ?덉쇅瑜?留뚮벊?덈떎.
+ * 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다.
*/
private JsonRpcException notFound(String name) {
return new JsonRpcException(
@@ -271,8 +305,8 @@ public class ToolRegistryService {
}
/**
- * 湲곗〈 snapshot??議댁옱?섍퀬 ??snapshot怨??ㅻ? ?뚮쭔 Tool 紐⑸줉 蹂寃??대깽?몃? 諛쒗뻾?⑸땲??
- * 理쒖큹 濡쒕뵫? Agent Builder媛 ?꾩쭅 紐⑸줉??諛쏄린 ?꾩씪 ???덉쑝誘濡??뚮┝ ??곸뿉???쒖쇅?섍퀬, ?ㅼ젣 援먯껜媛 諛쒖깮??refresh?먮쭔 ?곹뼢??以띾땲??
+ * 기존 snapshot이 존재하고 새 snapshot과 다를 때만 Tool 목록 변경 이벤트를 발행합니다.
+ * 최초 로딩은 Agent Builder가 아직 목록을 받기 전일 수 있으므로 알림 대상에서 제외하고, 실제 교체가 발생한 refresh에만 영향을 줍니다.
*/
private void publishListChangedIfNeeded(
String routeKey, List previous, List current) {
@@ -282,8 +316,8 @@ public class ToolRegistryService {
}
/**
- * 濡쒖뺄 寃利앹쓣 ?꾪빐 route蹂?in-memory snapshot??理쒖큹 ?깅줉?섍굅???ㅼ젣 蹂寃쎈맆 ?뚮쭔 INFO 濡쒓렇濡??④퉩?덈떎.
- * Portal ?먮뒗 Tool Service revision 蹂寃쎌씠 memory??諛섏쁺?섏뿀?붿? ?뺤씤?????덈룄濡?Tool metadata ?꾩껜瑜?湲곕줉?⑸땲??
+ * 로컬 검증을 위해 route별 in-memory snapshot이 최초 등록되거나 실제 변경될 때만 INFO 로그로 남깁니다.
+ * Portal 또는 Tool Service revision 변경이 memory에 반영되었는지 확인할 수 있도록 Tool metadata 전체를 기록합니다.
*/
private void logSnapshot(String routeKey, List previous, List current) {
boolean changed = previous == null || !previous.equals(current);
@@ -296,8 +330,8 @@ public class ToolRegistryService {
}
/**
- * 寃利?濡쒓렇???ъ슜??route蹂?snapshot ?댁슜??JSON 臾몄옄?대줈 蹂?섑빀?덈떎.
- * 吏곷젹???ㅽ뙣媛 refresh ?깃났 ?щ????곹뼢??二쇱? ?딅룄濡??ㅽ뙣 ??理쒖냼 臾몄옄???쒗쁽?쇰줈 ?泥댄빀?덈떎.
+ * 검증 로그에 사용할 route별 snapshot 내용을 JSON 문자열로 변환합니다.
+ * 직렬화 실패가 refresh 성공 여부에 영향을 주지 않도록 실패 시 최소 문자열 표현으로 대체합니다.
*/
private String snapshotJson(String routeKey, List current) {
Map body = new LinkedHashMap<>();
@@ -313,7 +347,7 @@ public class ToolRegistryService {
}
/**
- * route key??null怨?怨듬갚??湲곗〈 ?⑥씪 snapshot key??鍮?臾몄옄?대줈 ?뺢퇋?뷀빀?덈떎.
+ * route key의 null과 공백을 기존 단일 snapshot key인 빈 문자열로 정규화합니다.
*/
private String normalizeRouteKey(String routeKey) {
return routeKey == null ? "" : routeKey.trim();
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 88c5ef0..1e28095 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -85,7 +85,8 @@ mcp:
max-tool-timeout-millis: 30000
portal:
enabled: ${MCP_PORTAL_ENABLED:false}
- route-key: ${MCP_PORTAL_ROUTE_KEY:}
+ # route key는 설정이 아니라 요청 URI의 /mcp/{routeKey}에서만 결정된다(ADR-0010).
+ # 기본 route로 보정하면 잘못된 단일 진입점 호출이 조용히 성공하므로 여기에 두지 않는다.
registry-url: ${MCP_PORTAL_REGISTRY_URL:}
refresh-interval-seconds: ${MCP_PORTAL_REFRESH_INTERVAL_SECONDS:300}
# Declared per deployment. baseEndpoint is the execution address and is owned by this file only:
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
index c587abf..c1e0c06 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/TestFixtures.java
@@ -32,7 +32,7 @@ public final class TestFixtures {
new McpProperties.Trace(true, 1_048_576),
new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"),
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
- new McpProperties.Portal(false, "", "", 300),
+ new McpProperties.Portal(false, "", 300),
bundles);
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java
index f5415d8..c36d14d 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/config/McpBundleConfigurationTest.java
@@ -68,7 +68,7 @@ class McpBundleConfigurationTest {
null,
null,
new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000),
- new McpProperties.Portal(false, "", "", 300),
+ new McpProperties.Portal(false, "", 300),
List.of());
assertThat(properties.isDiscoveryTargetDeclared()).isFalse();
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/contract/PortalRegistryContractExampleTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/PortalRegistryContractExampleTest.java
new file mode 100644
index 0000000..da8e3e7
--- /dev/null
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/contract/PortalRegistryContractExampleTest.java
@@ -0,0 +1,172 @@
+package io.shinhanlife.dap.biz.mcp.contract;
+
+import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
+import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.shinhanlife.dap.biz.mcp.config.McpProperties;
+import io.shinhanlife.dap.biz.mcp.registry.PortalToolRegistryClient;
+import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
+import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Portal-MCP 계약 문서의 registry 예제 JSON을 직접 읽어 구현이 그 계약을 그대로 만족하는지 검증하는 계약 테스트입니다.
+ * 문서와 코드가 각자 표류하는 것을 막는 것이 목적이므로, 예제 파일을 고치면 이 테스트가 함께 깨져야 합니다.
+ * 예제의 {@code serviceDomain}만 MockWebServer 주소로 치환하고 나머지 필드는 파일 그대로 사용하므로,
+ * serviceDomain과 manifestPath를 조합해 매니페스트를 조회하는 실제 경로가 그대로 실행됩니다.
+ */
+class PortalRegistryContractExampleTest {
+
+ private static final Path EXAMPLES = Path.of("docs/contracts/portal-mcp/examples/registry-v0.1");
+
+ private static final String BUSINESS_DOMAIN = "http://tool-business.ax-hub.svc.cluster.local:8080";
+ private static final String EXTERNAL_DOMAIN = "http://tool-external.ax-hub.svc.cluster.local:8080";
+
+ private MockWebServer portal;
+ private MockWebServer businessToolServer;
+ private MockWebServer externalToolServer;
+
+ /**
+ * 예제 registry 응답을 돌려줄 포털과 route별 Tool Service를 각각 띄웁니다.
+ * route마다 서버를 분리해, 조회 순서에 의존하지 않고 route별 매니페스트 조회 대상을 검증할 수 있게 합니다.
+ */
+ @BeforeEach
+ void setUp() throws Exception {
+ portal = new MockWebServer();
+ portal.start();
+ businessToolServer = new MockWebServer();
+ businessToolServer.start();
+ externalToolServer = new MockWebServer();
+ externalToolServer.start();
+ }
+
+ /**
+ * 띄운 서버를 정리합니다.
+ */
+ @AfterEach
+ void tearDown() throws Exception {
+ portal.shutdown();
+ businessToolServer.shutdown();
+ externalToolServer.shutdown();
+ }
+
+ @Test
+ void buildsRouteCatalogFromTheContractAggregateExampleExactlyAsDocumented() throws Exception {
+ portal.enqueue(json(exampleWithLocalDomains("aggregate-registry-response.json")));
+ businessToolServer.enqueue(json(manifest("business-tools", "business.customer_search")));
+ externalToolServer.enqueue(json(manifest("external-tools", "external.weather_lookup")));
+
+ Map> catalog = client().fetchAllTools();
+
+ assertThat(catalog).containsOnlyKeys("business", "external");
+ assertThat(catalog.get("business")).extracting(ToolMetadata::name)
+ .containsExactly("business.customer_search");
+ assertThat(catalog.get("external")).extracting(ToolMetadata::name)
+ .containsExactly("external.weather_lookup");
+ assertThat(businessToolServer.takeRequest().getPath()).isEqualTo("/tool-manifest");
+ assertThat(externalToolServer.takeRequest().getPath()).isEqualTo("/tool-manifest");
+ }
+
+ @Test
+ void readsTheContractSingleRouteExampleAsOneRouteDocument() throws Exception {
+ portal.enqueue(json(exampleWithLocalDomains("route-registry-response.json")));
+ externalToolServer.enqueue(json(manifest("external-tools", "external.exchange_rate")));
+
+ List tools = client().fetchTools("external");
+
+ assertThat(tools).extracting(ToolMetadata::name).containsExactly("external.exchange_rate");
+ assertThat(externalToolServer.takeRequest().getPath()).isEqualTo("/tool-manifest");
+ assertThat(businessToolServer.getRequestCount()).isZero();
+ }
+
+ /**
+ * 계약 예제 JSON을 읽어 문서용 service domain만 실제 MockWebServer 주소로 치환합니다.
+ * 나머지 필드를 그대로 두어야 예제가 구현의 입력으로 검증되므로 domain 외의 값은 바꾸지 않습니다.
+ */
+ private String exampleWithLocalDomains(String fileName) throws Exception {
+ return Files.readString(EXAMPLES.resolve(fileName))
+ .replace(BUSINESS_DOMAIN, trimTrailingSlash(businessToolServer.url("").toString()))
+ .replace(EXTERNAL_DOMAIN, trimTrailingSlash(externalToolServer.url("").toString()));
+ }
+
+ /**
+ * 포털 registry 응답만 계약 예제로 고정하고, Tool Service 매니페스트는 이 계약의 소유가 아니므로 최소 형태로 만듭니다.
+ */
+ private String manifest(String bundleId, String toolName) {
+ return """
+ {
+ "bundleId": "%s",
+ "revision": "manifest-1",
+ "tools": [
+ {
+ "name": "%s",
+ "description": "contract example tool",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string"
+ }
+ }
+ },
+ "_meta": {
+ "version": "1.0.0",
+ "enabled": true
+ }
+ }
+ ]
+ }
+ """
+ .formatted(bundleId, toolName);
+ }
+
+ /**
+ * 계약 문서 3절의 설정 예시와 같은 상태, 즉 포털이 endpoint 원천이고 {@code mcp.bundles}가 비어 있는 client를 만듭니다.
+ */
+ private PortalToolRegistryClient client() {
+ RestClient restClient = RestClient.builder()
+ .requestFactory(new SimpleClientHttpRequestFactory())
+ .build();
+ McpProperties base = properties(false, false);
+ McpProperties mcpProperties = new McpProperties(
+ base.identity(),
+ base.endpointPath(),
+ base.server(),
+ base.registry(),
+ base.toolClient(),
+ base.redis(),
+ base.trace(),
+ base.protocol(),
+ new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000),
+ new McpProperties.Portal(true, portal.url("/api/portal/registry").toString(), 300),
+ List.of());
+ ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
+ return new PortalToolRegistryClient(restClient, mcpProperties, discovery, Optional.empty());
+ }
+
+ /**
+ * MockWebServer가 돌려주는 base URL 끝의 slash를 제거해 예제의 service domain 형태와 맞춥니다.
+ */
+ private String trimTrailingSlash(String value) {
+ return value.replaceAll("/+$", "");
+ }
+
+ /**
+ * 계약 예제를 JSON 응답으로 감쌉니다.
+ */
+ private MockResponse json(String body) {
+ return new MockResponse().setHeader("Content-Type", "application/json").setBody(body);
+ }
+}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java
index 581b531..636e6bf 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/PortalToolRegistryClientTest.java
@@ -25,6 +25,7 @@ class PortalToolRegistryClientTest {
private MockWebServer portal;
private MockWebServer toolServer;
+ private MockWebServer brokenToolServer;
@BeforeEach
void setUp() throws Exception {
@@ -32,12 +33,15 @@ class PortalToolRegistryClientTest {
portal.start();
toolServer = new MockWebServer();
toolServer.start();
+ brokenToolServer = new MockWebServer();
+ brokenToolServer.start();
}
@AfterEach
void tearDown() throws Exception {
portal.shutdown();
toolServer.shutdown();
+ brokenToolServer.shutdown();
}
@Test
@@ -107,6 +111,70 @@ class PortalToolRegistryClientTest {
assertThat(toolServer.getRequestCount()).isZero();
}
+ @Test
+ void keepsHealthyRoutesWhenAnotherRouteToolServiceNeverSucceeds() {
+ portal.enqueue(jsonResponse(twoRoutePortalRegistryJson("portal-1")));
+ toolServer.enqueue(manifest("manifest-1", "external.weather"));
+ brokenToolServer.enqueue(new MockResponse().setResponseCode(503));
+
+ Map> snapshots = client().fetchAllTools();
+
+ assertThat(snapshots).containsOnlyKeys("external");
+ assertThat(snapshots.get("external"))
+ .extracting(ToolMetadata::name)
+ .containsExactly("external.weather");
+ }
+
+ @Test
+ void reportsEveryRouteTheRegistryDeclaredEvenWhenItsCatalogFailed() {
+ portal.enqueue(jsonResponse(twoRoutePortalRegistryJson("portal-1")));
+ toolServer.enqueue(manifest("manifest-1", "external.weather"));
+ brokenToolServer.enqueue(new MockResponse().setResponseCode(503));
+ PortalToolRegistryClient client = client();
+
+ client.fetchAllTools();
+
+ assertThat(client.knownRoutes()).containsExactlyInAnyOrder("external", "broken");
+ }
+
+ private String twoRoutePortalRegistryJson(String revision) {
+ return """
+ {
+ "registryRevision": "%s",
+ "routes": [
+ {
+ "routeKey": "external",
+ "toolServices": [
+ {
+ "serviceKey": "external-tool-server",
+ "serviceDomain": "%s",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "external.",
+ "status": "ACTIVE"
+ }
+ ]
+ },
+ {
+ "routeKey": "broken",
+ "toolServices": [
+ {
+ "serviceKey": "broken-tool-server",
+ "serviceDomain": "%s",
+ "manifestPath": "/tool-manifest",
+ "namePrefix": "broken.",
+ "status": "ACTIVE"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .formatted(
+ revision,
+ toolServer.url("").toString().replaceAll("/+$", ""),
+ brokenToolServer.url("").toString().replaceAll("/+$", ""));
+ }
+
private PortalToolRegistryClient client() {
return client(Optional.empty());
}
@@ -126,7 +194,7 @@ class PortalToolRegistryClientTest {
base.trace(),
base.protocol(),
base.discovery(),
- new McpProperties.Portal(true, "", portal.url("/api/portal/registry").toString(), 15),
+ new McpProperties.Portal(true, portal.url("/api/portal/registry").toString(), 15),
List.of());
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
return new PortalToolRegistryClient(restClient, mcpProperties, discovery, redisPortalRegistryCache);
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
index c5a3641..d0cd566 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryRefreshSchedulerTest.java
@@ -1,14 +1,29 @@
package io.shinhanlife.dap.biz.mcp.registry;
+import static org.mockito.Mockito.inOrder;
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.mockito.InOrder;
class ToolRegistryRefreshSchedulerTest {
+ @Test
+ void loadsRouteRegistryBeforeWarmStartSoRouteScopedCacheKeysAreKnown() {
+ ToolRegistryService service = mock(ToolRegistryService.class);
+ ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service);
+
+ scheduler.preload();
+
+ InOrder order = inOrder(service);
+ order.verify(service).refreshSourceRegistry();
+ order.verify(service).warmStartFromSharedCache();
+ order.verify(service).refreshKnownRoutes();
+ }
+
@Test
void refreshesManifestImmediatelyWhenPortalRegistryChanges() {
ToolRegistryService service = mock(ToolRegistryService.class);
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java
index b5580b3..755dddd 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolRegistryServiceTest.java
@@ -18,6 +18,7 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@@ -150,7 +151,26 @@ class ToolRegistryServiceTest {
.extracting(ToolMetadata::endpoint)
.isEqualTo("http://shared-tool");
verify(redis, times(1)).loadSnapshot("");
- verifyNoInteractions(client);
+ // warm start는 route 목록만 물어보고 원천 조회는 하지 않는다.
+ verify(client, never()).fetchTools(any());
+ verify(client, never()).fetchAllTools();
+ }
+
+ @Test
+ void warmStartsEveryRouteTheSourceDeclares() {
+ ToolRegistryClient client = mock(ToolRegistryClient.class);
+ RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
+ when(client.knownRoutes()).thenReturn(Set.of("external", "business"));
+ when(redis.loadSnapshot("external"))
+ .thenReturn(Optional.of(List.of(tool("http://external-tool"))));
+ when(redis.loadSnapshot("business"))
+ .thenReturn(Optional.of(List.of(tool("http://business-tool"))));
+ ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
+
+ service.warmStartFromSharedCache();
+
+ assertThat(service.readyRouteKeys()).containsExactlyInAnyOrder("external", "business");
+ verify(redis, never()).loadSnapshot("");
}
@Test
@@ -226,6 +246,47 @@ class ToolRegistryServiceTest {
verify(client, never()).fetchTools("business");
}
+ @Test
+ void keepsSnapshotOfARouteWhoseCatalogFailedWhileTheSourceStillDeclaresIt() {
+ ToolRegistryClient client = mock(ToolRegistryClient.class);
+ when(client.knownRoutes()).thenReturn(Set.of("external", "business"));
+ when(client.fetchAllTools())
+ .thenReturn(Map.of(
+ "external", List.of(tool("http://external-tool")),
+ "business", List.of(tool("http://business-tool"))))
+ .thenReturn(Map.of("external", List.of(tool("http://external-tool"))));
+ ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
+
+ service.refreshKnownRoutes();
+ service.refreshKnownRoutes();
+
+ assertThat(service.listTools("business"))
+ .singleElement()
+ .extracting(ToolMetadata::endpoint)
+ .isEqualTo("http://business-tool");
+ assertThat(service.readyRouteKeys()).containsExactlyInAnyOrder("external", "business");
+ verify(client, never()).fetchTools("business");
+ }
+
+ @Test
+ void removesRouteOnlyWhenTheSourceStopsDeclaringIt() {
+ ToolRegistryClient client = mock(ToolRegistryClient.class);
+ when(client.knownRoutes())
+ .thenReturn(Set.of("external", "business"))
+ .thenReturn(Set.of("external"));
+ when(client.fetchAllTools())
+ .thenReturn(Map.of(
+ "external", List.of(tool("http://external-tool")),
+ "business", List.of(tool("http://business-tool"))))
+ .thenReturn(Map.of("external", List.of(tool("http://external-tool"))));
+ ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
+
+ service.refreshKnownRoutes();
+ service.refreshKnownRoutes();
+
+ assertThat(service.readyRouteKeys()).containsExactly("external");
+ }
+
@Test
void publishesToolsListChangedOnlyWhenExistingRouteSnapshotChanges() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java
index 243665f..46b28f9 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/transport/http/McpExchangeFilterTest.java
@@ -302,7 +302,7 @@ class McpExchangeFilterTest {
base.trace(),
base.protocol(),
base.discovery(),
- new McpProperties.Portal(true, "", "http://portal.test/api/registry", 15),
+ new McpProperties.Portal(true, "http://portal.test/api/registry", 15),
base.bundles());
McpExchangeFilter filter = filter(portalEnabled);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");