Portal을 endpoint 원천으로 확정하고 route 단위 실패 격리를 적용한다

내부망 운영은 route↔Tool Service 매핑을 Portal이 소유하고, MCP 배포 하나가 N개
route를 서비스하며, route 하나에 N개 Tool Service가 붙을 수 있다. 이 판단을
ADR-0010으로 남기고 ADR-0007 전체와 ADR-0009 결정 4를 대체한다.

계약
- Portal-MCP registry 조회 계약 v0.1과 예제 JSON을 docs/contracts/portal-mcp/에
  신설한다. 지금까지 이 경로에는 정본이 없었다.
- 예제를 PortalToolRegistryClient의 실제 파싱 경로에 태우는 계약 테스트를 추가해
  문서와 구현이 따로 표류하지 않게 한다.

실패 격리
- fetchAllTools()의 실패 전파를 route 단위로 격리한다. 계약 v0.2의 "aggregate는
  전부 아니면 전무"는 카탈로그 하나를 전제한 규칙인데, route가 N개가 되면서 전
  route로 확대돼 있었다. Tool Service 하나의 장애가 cold start에서 Pod 전체를
  내리고 steady state에서 모든 route의 갱신을 멈추던 동작을 없앤다.
- 제거 판단의 원천을 ToolRegistryClient.knownRoutes()로 분리한다. 조회 결과를
  기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라져 "어떤
  실패도 목록을 비우지 않는다" 불변식이 깨진다.
- readiness는 최소 1개 route로 UP을 유지한다. 모든 route를 요구하면 정상 route까지
  트래픽에서 빠져 위 격리를 되돌리기 때문이다. 대신 routesWithoutSnapshot을
  health detail로 노출해 관제가 부분 상태를 감지하게 한다.

설정과 기동
- warm start가 route별 Redis key를 읽도록 확장하고, 읽을 key를 알기 위해 기동
  preload 순서를 registry 조회 → warm start → manifest 조회로 바꾼다.
- 어떤 코드도 읽지 않던 mcp.portal.route-key를 제거한다. route key는 요청 URI에서만
  결정되며, 설정으로 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.

정리
- ToolRegistryService.java의 이중 인코딩으로 깨져 있던 한글 Javadoc 33줄을 코드
  동작에 맞춰 다시 쓰고, replaceSnapshot 위에 겹쳐 있던 고아 Javadoc 블록을 지운다.
- Helm chart는 mcp.bundles 구성에서 계속 유효하므로 삭제하지 않고, 내부망 운영
  대상이 아니라는 사실을 deploy/README.md와 values.yaml에 명시한다.

검증: 이 환경은 loopback이 막혀 gradlew check를 실행하지 못했다. CodeStyleContract가
보는 항목(줄바꿈, 탭, 행말 공백, 파일 끝 개행, 미사용 import, import 순서)은 변경된
Java 13개 파일에 대해 따로 재현해 확인했다. 컴파일과 테스트 실행은 미확인이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:10:34 +09:00
parent 189277a78c
commit 1e6fa8f22f
27 changed files with 1001 additions and 55 deletions

View File

@@ -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 주소로 치환하며,
나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**