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:
@@ -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`로 확인한다.
|
||||
|
||||
|
||||
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 주소로 치환하며,
|
||||
나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**
|
||||
@@ -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 결정은 그대로 유지된다.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 배경
|
||||
|
||||
@@ -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 추가가 배포 파이프라인을 건드린다.
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user