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:
51
docs/contracts/portal-mcp/README.md
Normal file
51
docs/contracts/portal-mcp/README.md
Normal file
@@ -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)을 따른다.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: []
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
227
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal file
227
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal 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 주소로 치환하며,
|
||||
나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**
|
||||
Reference in New Issue
Block a user