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");