Portal registry 결정을 문서로 확정하고 ADR 상태를 코드에 맞춘다
ADR-0007은 MCP 배포 하나가 Tool Service 하나만 보게 했으나 구현은 이미 Portal이 route와 endpoint를 소유하는 구조다. 결정 문서가 없어 ADR-0007이 Accepted로 남은 채 코드와 정반대되는 내용을 현재 설계 근거처럼 제시하고 있었다. ADR-0013이 그 경로를 확정하고 ADR-0007 전체와 ADR-0009 결정 4를 대체한다. ADR-0010은 Tool 실행 주소의 소유자가 설정이 아니라 Tool Service 매니페스트라는 6078852의 결정을 사후 기록한다. 두 ADR이 정본으로 인용하는 Portal-MCP 계약 v0.1도 함께 넣는다. ADR-0013은 main 현재 코드에 맞춰 세 곳을 고쳤다. route 간 갱신 격리 부재는 6653030이 해소해 격리 표로 옮겼고, 제거된 mcp.portal.route-key 항목은 뺐다. 남은 위험 둘(Portal 모드 warm start 미동작, readiness의 route별 상태 미노출)은 코드에서 유효함을 확인해 남긴다. ADR-0010이 기록하는 대로 endpoint 검증에는 도메인 허용목록이 없고 NetworkPolicy도 Ingress만 선언한다. 매니페스트 원천의 신뢰성이 곧 outbound 대상의 신뢰성이다. McpProperties와 application.yml의 주석이 이 결정과 반대로 남아 있다는 사실도 ADR에 적었다. 코드는 바꾸지 않는다. 192개 테스트 통과. 문서 상대링크 깨짐 없음. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
36
docs/contracts/portal-mcp/README.md
Normal file
36
docs/contracts/portal-mcp/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Portal-MCP 계약 문서
|
||||||
|
|
||||||
|
이 디렉터리는 포털과 MCP Server 사이의 Tool Server registry 조회 계약을 관리한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Portal ──[portal-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Server
|
||||||
|
▲
|
||||||
|
└──[agent-builder-mcp 계약]── Agent Builder
|
||||||
|
```
|
||||||
|
|
||||||
|
| 문서 | 상태 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| [protocol-v0.1-registry.md](protocol-v0.1-registry.md) | Implemented | route별 Tool Server 목록 조회 계약. 응답 형태, 필드, 실패 동작, 갱신 주기 |
|
||||||
|
|
||||||
|
## 현재 원칙
|
||||||
|
|
||||||
|
- 포털은 **route가 무엇이고 그 route에 어떤 Tool Server가 있는가**만 소유한다. `serviceDomain`과 `manifestPath`까지다.
|
||||||
|
- Tool 목록과 Tool 실행 endpoint는 포털이 아니라 Tool Server 매니페스트에서 온다([ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||||
|
- registry 응답은 그 시점의 전체 상태다. 증분은 없다.
|
||||||
|
- 조회 실패는 route 삭제가 아니다. 성공한 registry가 route를 제외했을 때만 제거를 반영한다.
|
||||||
|
- route 조회는 서로 독립이지만, 한 route 안에서는 전부 아니면 전무다. 부분 목록으로 snapshot을 만들지 않는다.
|
||||||
|
- MCP는 요청 경로에서 포털을 호출하지 않는다. route key 검증도 in-memory snapshot만 본다.
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- 요청 URL의 route key 규약: [Agent Builder 계약 v0.3](../agent-builder-mcp/protocol-v0.3-streaming-policy.md#공개-url과-route-key)
|
||||||
|
- 매니페스트 조회·실행 계약: [Tool Service 계약 v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md)
|
||||||
|
|
||||||
|
## 운영 적용 전 확정할 항목
|
||||||
|
|
||||||
|
1. 포털 API의 인증 방식과 MCP → 포털 방향 NetworkPolicy
|
||||||
|
2. `registryRevision`의 형식과 변경 통지 방식
|
||||||
|
3. route 추가·폐기 시 rolling 호환 기간
|
||||||
|
4. 저장소 샘플(`config/local-toolserver-info-sample-v1.json`, `deploy/portal-registry.json`)을 이 계약에 맞추는 시점
|
||||||
|
|
||||||
|
상세 필드와 장애 처리는 [v0.1 계약](protocol-v0.1-registry.md)을 따른다.
|
||||||
117
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal file
117
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Portal-MCP Tool Server Registry 계약 v0.1
|
||||||
|
|
||||||
|
- 상태: **Implemented** (MCP 서버 측 구현 완료, 포털 측 합의 대기)
|
||||||
|
- 기준일: 2026-08-22
|
||||||
|
- 조회 위치: `mcp.portal.registry-url` — HTTP(S) Portal API 또는 `file:`/`classpath:` 로컬 리소스
|
||||||
|
- 활성 조건: `mcp.portal.enabled=true`
|
||||||
|
- 구현: `PortalToolRegistryClient`
|
||||||
|
|
||||||
|
## 1. 계약 범위와 원칙
|
||||||
|
|
||||||
|
포털은 **route별로 어떤 Tool Server가 있는가**만 알려 준다. Tool 목록과 Tool 실행 주소는 포털이 아니라 각 Tool Server의 매니페스트에서 온다([Tool Service-MCP Bundle 조회 계약 v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md), [ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||||
|
|
||||||
|
| 원칙 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| MCP가 가져온다 | 포털은 registry를 제공만 한다. MCP에 push하지 않는다 |
|
||||||
|
| 포털은 route와 Tool Server만 소유 | `serviceDomain`과 `manifestPath`까지다. Tool 목록·실행 endpoint는 매니페스트가 정한다 |
|
||||||
|
| registry는 전체 상태 | 응답은 그 시점 route 전체다. 증분 없음 |
|
||||||
|
| route 단위 격리 | 한 route의 manifest 조회 실패가 다른 route의 snapshot을 지우지 않는다 |
|
||||||
|
| 요청 경로는 조회하지 않는다 | `tools/list`·`tools/call`과 route key 검증은 in-memory snapshot만 본다 |
|
||||||
|
|
||||||
|
## 2. 응답 형태
|
||||||
|
|
||||||
|
두 가지를 모두 받는다. `routes`가 배열이면 집계형으로, 아니면 단일 route로 해석한다.
|
||||||
|
|
||||||
|
**집계형** — 한 번의 호출로 모든 route를 받는다. 운영에서 사용한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registryRevision": "portal-registry-2026-08-22-01",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"routeKey": "cus",
|
||||||
|
"toolServices": [
|
||||||
|
{
|
||||||
|
"serviceKey": "was-cus",
|
||||||
|
"serviceDomain": "https://tool-cus.devjun.net",
|
||||||
|
"manifestPath": "/tool-manifest",
|
||||||
|
"namePrefix": "",
|
||||||
|
"status": "ACTIVE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**단일 route형** — `routes` 없이 최상위에 `routeKey`와 `toolServices`를 둔다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"routeKey": "cus",
|
||||||
|
"toolServices": [ { "serviceKey": "was-cus", "serviceDomain": "https://tool-cus.devjun.net", "manifestPath": "/tool-manifest", "status": "ACTIVE" } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 필드
|
||||||
|
|
||||||
|
| 필드 | 위치 | 필수 | MCP 처리 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `routes[]` | 최상위 | 선택 | 배열이면 집계형. 없으면 최상위를 단일 route로 읽는다 |
|
||||||
|
| `routeKey` | route | **필수** | 정규화 후 route 식별자. 요청 URL `/mcp/{routeKey}`와 대조한다 |
|
||||||
|
| `toolServices[]` | route | **필수** | 이 route가 보는 Tool Server 목록 |
|
||||||
|
| `serviceKey` | service | **필수** | bundle id로 사용. 매니페스트의 `bundleId`와 일치해야 한다 |
|
||||||
|
| `serviceDomain` | service | **필수** | Tool Server 주소. 후행 `/`는 제거한다. 매니페스트의 **상대** endpoint를 절대 URL로 바꾸는 기준이 된다 |
|
||||||
|
| `manifestPath` | service | **필수** | 매니페스트 경로. `serviceDomain + manifestPath`가 조회 주소다 |
|
||||||
|
| `status` | service | 선택 | 기본 `ACTIVE`. 대소문자 무시하고 `ACTIVE`가 아니면 그 서비스를 건너뛴다 |
|
||||||
|
| `namePrefix` | service | 선택 | 기본 `""`. Tool 이름 접두사 검증에 사용한다 |
|
||||||
|
| `registryRevision` | 최상위 | 선택 | 변경 진단·로그용. 호출 대상 결정에는 쓰지 않는다 |
|
||||||
|
|
||||||
|
**MCP가 읽지 않는 필드가 있다.** 현재 구현은 `executeBasePath`, `displayName`, `toolEndpoints`를 무시한다. 응답에 있어도 오류가 아니지만 동작에 영향을 주지 않으므로, 포털이 이를 근거로 실행 주소를 통제할 수 있다고 가정하면 안 된다.
|
||||||
|
|
||||||
|
## 4. 실패 동작
|
||||||
|
|
||||||
|
| 상황 | MCP 동작 |
|
||||||
|
|---|---|
|
||||||
|
| registry 조회 실패 | 이미 확보한 in-memory endpoint 목록 유지. memory가 비어 있으면 `mcp.redis.portal-registry-key`의 Redis fallback을 읽는다 |
|
||||||
|
| Redis fallback도 실패 | 원천 미확보로 처리하고 다음 주기에 재시도 |
|
||||||
|
| route에 ACTIVE 서비스가 하나도 없음 | `Portal registry has no active Tool Service`로 그 route 조회 실패 |
|
||||||
|
| 한 Tool Server의 매니페스트에 사용 가능한 성공본이 없음 | 그 route 전체를 실패 처리. 부분 목록을 채택하지 않는다 |
|
||||||
|
| route 간 Tool name 중복 또는 `maxToolsTotal` 초과 | 같은 이유로 실패 처리 |
|
||||||
|
| registry에서 사라진 route | 다음 갱신에 in-memory snapshot에서도 제거 |
|
||||||
|
|
||||||
|
route 단위 격리와 catalog 교체 규칙은 Tool Service 계약 v0.2 §7과 같은 원칙을 따른다. 조회는 route마다 독립이지만, 한 route 안에서는 전부 아니면 전무다.
|
||||||
|
|
||||||
|
## 5. 갱신 주기
|
||||||
|
|
||||||
|
- `mcp.portal.refresh-interval-seconds` (기본 300초): 포털 registry만 다시 읽는다.
|
||||||
|
- `mcp.registry.refresh-interval-seconds`: 이미 확보한 Tool Server 목록의 매니페스트만 다시 읽는다.
|
||||||
|
|
||||||
|
기동 preload는 포털 registry를 먼저 호출한 뒤 매니페스트를 조회한다. 두 주기는 독립이다.
|
||||||
|
|
||||||
|
## 6. 로컬 검증
|
||||||
|
|
||||||
|
`registry-url`에 `file:` 또는 `classpath:` 리소스를 지정하면 포털 서버 없이 같은 계약으로 읽는다. 이후 매니페스트 조회·route별 snapshot 갱신·Redis fallback 규칙은 HTTP API를 쓸 때와 동일하다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp:
|
||||||
|
portal:
|
||||||
|
enabled: true
|
||||||
|
registry-url: file:./config/local-toolserver-info-sample-v1.json
|
||||||
|
refresh-interval-seconds: 15
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 저장소의 샘플 파일
|
||||||
|
|
||||||
|
두 샘플이 있고, 현재 서로 다르다. 이 계약을 정본으로 삼고 맞춰야 한다.
|
||||||
|
|
||||||
|
| 파일 | 용도 | 이 계약과의 차이 |
|
||||||
|
|---|---|---|
|
||||||
|
| `config/local-toolserver-info-sample-v1.json` | 로컬 검증용 | MCP가 읽지 않는 `displayName`을 포함 |
|
||||||
|
| `deploy/portal-registry.json` | 배포 참고용 | MCP가 읽지 않는 `executeBasePath`를 포함하고 `registryRevision`이 없다 |
|
||||||
|
|
||||||
|
## 8. 열린 항목
|
||||||
|
|
||||||
|
- 포털 API의 인증 방식은 이 계약이 정하지 않는다. MCP는 인증·인가를 하지 않으므로([ADR-0006](../../decisions/ADR-0006-no-authentication-in-mcp.md)) 네트워크 경계에서 통제한다.
|
||||||
|
- `registryRevision`의 형식을 문자열로 고정할지 정하지 않았다. 현재 구현은 값을 로그·진단에만 쓰므로 형식에 의존하지 않는다.
|
||||||
|
- 즉시 refresh 알림: 주기 반영으로 부족하다는 운영 근거가 생길 때 검토한다.
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
# ADR-0007 MCP 배포 하나는 Tool Service 하나만 본다
|
# ADR-0007 MCP 배포 하나는 Tool Service 하나만 본다
|
||||||
|
|
||||||
- 상태: Accepted
|
- 상태: Superseded
|
||||||
- 결정일: 2026-08-02
|
- 결정일: 2026-08-02
|
||||||
|
- 대체 결정: [ADR-0013](ADR-0013-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)
|
- 관련: [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-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)을 적용한다.
|
||||||
|
> 아래 격리 논거는 폐기된 것이 아니라 ADR-0013이 무엇을 포기했는지 판단하는 근거로 남는다.
|
||||||
|
|
||||||
외부에서 여러 MCP를 하나의 host 아래 path로 묶는 방식은 [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)이
|
외부에서 여러 MCP를 하나의 host 아래 path로 묶는 방식은 [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)이
|
||||||
소유한다. OpenShift Route가 원래 path를 유지한 채 각각의 독립 배포로 연결하므로 이 ADR의 1:1 결정은 그대로 유지된다.
|
소유한다. OpenShift Route가 원래 path를 유지한 채 각각의 독립 배포로 연결하므로 이 ADR의 1:1 결정은 그대로 유지된다.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
- 상태: Accepted
|
- 상태: Accepted
|
||||||
- 결정일: 2026-08-05
|
- 결정일: 2026-08-05
|
||||||
- 대체: [ADR-0008](ADR-0008-shared-host-path-routing.md)
|
- 대체: [ADR-0008](ADR-0008-shared-host-path-routing.md)
|
||||||
|
- 부분 대체됨: 결정 4는 [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)이 대체한다
|
||||||
- 관련: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
|
- 관련: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
|
||||||
|
|
||||||
## 배경
|
## 배경
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ADR-0010 Tool 실행 endpoint를 Tool Service 매니페스트가 선언한다
|
||||||
|
|
||||||
|
- 상태: Accepted
|
||||||
|
- 결정일: 2026-08-19
|
||||||
|
- 기록: 2026-08-22. 구현 커밋 `6078852`를 기준으로 사후 작성했다.
|
||||||
|
- 관련: [ADR-0006](ADR-0006-no-authentication-in-mcp.md), [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
이전 설계에서 Tool 실행 주소는 MCP 배포 설정이 단독으로 소유했다. `mcp.bundles[].baseEndpoint` 뒤에 Tool name을 붙여 `POST {baseEndpoint}/{toolName}`을 만들었고, 매니페스트가 endpoint 성격의 값을 담고 있어도 읽지 않았다. **Tool Service가 반환한 어떤 값도 MCP가 요청을 보내는 대상을 바꿀 수 없다**는 것이 이 설계의 핵심이었고, `docs/architecture.md`, Tool Service 계약 v0.2, `ToolBundleDiscovery`의 Javadoc, `application.yml` 주석이 같은 문장을 반복해 고정하고 있었다.
|
||||||
|
|
||||||
|
Portal registry를 Tool Server 목록의 원천으로 도입하면서 전제가 무너졌다. 포털은 route별로 Tool Server의 `serviceDomain`과 `manifestPath`만 제공한다. Tool 하나하나의 실행 경로는 포털이 모르고, MCP 배포 설정도 미리 알 수 없다. 기존 구조를 유지하려면 Tool을 추가하거나 경로를 바꿀 때마다 배포 설정의 endpoint 목록을 함께 고쳐야 했고, 이는 Tool Service의 배포 주기와 MCP의 배포 주기를 묶어 버린다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
1. Tool 실행 주소는 Tool Service 매니페스트가 선언한다. MCP는 각 Tool의 top-level `endpoint`를 먼저 읽고, 없으면 `_meta.endpoint`를 사용한다.
|
||||||
|
2. 둘 다 없거나 비어 있으면 그 Bundle 전체를 거부한다. Tool 하나의 누락이 나머지 Tool을 조용히 통과시키지 않는다.
|
||||||
|
3. 상대 경로는 Portal registry가 제공한 `serviceDomain` 뒤에 붙여 절대 URL로 만든다. 이것이 운영에서 기대하는 형태다.
|
||||||
|
4. 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용한다. scheme이 HTTP(S)가 아니거나 host가 없으면 거부한다. 프로토콜 상대 주소(`//host/path`)와 개행이 섞인 값도 거부한다.
|
||||||
|
5. `mcp.bundles[].baseEndpoint`는 더 이상 실행 주소의 정본이 아니다. 상대 경로를 해석하는 기준으로만 남으며, 절대 HTTP(S)여야 한다.
|
||||||
|
6. `endpoint`는 내부 실행 정보이므로 `_meta`와 함께 제거해 `tools/list` 공개본에 내보내지 않는다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
manifest endpoint = "/mcp/processing.contract.inquiry" (운영 관례)
|
||||||
|
-> https://tool-cus.devjun.net/mcp/processing.contract.inquiry
|
||||||
|
|
||||||
|
manifest endpoint = "https://other.example/tool" (허용되지만 위험)
|
||||||
|
-> https://other.example/tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
- Tool을 추가하거나 실행 경로를 바꿀 때 MCP 배포 설정을 함께 바꾸지 않아도 된다. Tool Service가 매니페스트만 갱신하면 다음 refresh에 반영된다.
|
||||||
|
- **신뢰 경계가 이동한다.** 이전에는 배포 설정이 outbound 대상을 봉인했으나, 이제는 매니페스트가 결정한다. 매니페스트가 절대 URL을 선언하면 MCP는 그 호스트로 요청을 보낸다.
|
||||||
|
- 검증은 두 지점에 있다. discovery 시점에 `ToolBundleDiscovery`가 scheme·host·프로토콜 상대 주소·개행을 확인하고, 실행 시점에 `ToolRoutingService.validateEndpoint()`가 절대 HTTP(S)인지 다시 확인한다. 둘 다 **형식 검사이며 도메인 허용목록은 없다.** 따라서 매니페스트 원천의 신뢰성이 곧 outbound 대상의 신뢰성이다.
|
||||||
|
- 네트워크 계층의 완화도 없다. `deploy/helm/mcp-server/templates/networkpolicy.yaml`은 `policyTypes: [Ingress]`만 선언하므로 outbound 목적지를 제한하지 않는다. 이 저장소가 제공하는 allowlist(`route.sourceAllowlist`, NetworkPolicy)는 모두 inbound 통제다.
|
||||||
|
- [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라 MCP는 인증·인가를 하지 않는다. 그래서 "`GET /tool-manifest`를 NetworkPolicy로 MCP Server namespace에서만 접근 가능하게 한다"는 기존 요구가 선택적 권고가 아니라 이 결정의 전제 조건이 된다.
|
||||||
|
- endpoint 검증 실패는 Bundle 전체 거부로 처리되고 직전 정상 snapshot이 유지되므로, 잘못된 매니페스트 배포가 기존 Tool 목록을 지우지는 않는다.
|
||||||
|
- 계약 문서와 예제가 함께 갱신됐다. `docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json`이 `endpoint`를 포함하며, `ToolBundleContractExampleTest`가 문서와 구현의 일치를 고정한다.
|
||||||
|
|
||||||
|
## 남은 위험
|
||||||
|
|
||||||
|
- **도메인 허용목록 부재.** 매니페스트가 임의의 HTTP(S) 호스트를 지정할 수 있고, 애플리케이션 검사도 네트워크 정책도 이를 좁히지 않는다. 내부망 운영 전에 두 방향 중 하나를 정해야 한다.
|
||||||
|
- `mcp.tool-domains` 형태의 allowlist를 두고 절대 URL을 그에 대조한다.
|
||||||
|
- 절대 URL을 아예 거부하고 상대 경로만 허용해 목적지를 Portal registry의 `serviceDomain`으로 봉인한다. 운영 예제가 이미 상대 경로만 쓰고 있어 비용이 가장 낮고, 이전 설계의 "Tool Service가 호출 대상을 바꿀 수 없다"는 성질도 회복된다.
|
||||||
|
- egress NetworkPolicy를 함께 검토한다. 위 두 방안 중 무엇을 택하든 애플리케이션 단독 방어보다 낫다.
|
||||||
|
- 이 ADR은 기존 ADR을 대체하지 않는다. 뒤집힌 불변식이 ADR이 아니라 architecture 문서와 코드 주석에만 있었기 때문이다. 같은 일이 반복되지 않도록 실행 주소 관련 결정은 앞으로 이 ADR을 갱신하거나 후속 ADR로 남긴다.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# ADR-0013 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다
|
||||||
|
|
||||||
|
- 상태: Accepted
|
||||||
|
- 결정일: 2026-08-22
|
||||||
|
- 대체 결정: [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) · [ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.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 하나만 보게 하고, 어떤 Tool Service를 볼지를 `mcp.bundles`에 배포 시점으로 못박았다. 전제는 **매핑이 배포 시점에 확정된다**는 것이었다.
|
||||||
|
|
||||||
|
내부망 운영은 그 전제를 따르지 않는다. route와 Tool Service의 매핑은 Portal이 관리하고, MCP는 기동 preload와 주기 refresh에서 Portal registry를 읽어 매핑을 받는다. 매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.
|
||||||
|
|
||||||
|
구현은 이미 이 구조였다. `PortalToolRegistryClient`가 registry 응답을 `Map<String, List<Bundle>>`(route → Tool Service 목록)로 유지하고, `McpRequestContextFactory`가 `/mcp/{routeKey}`에서 route를 뽑고, `ToolRegistryService`가 route별 snapshot을 들고 있다. 그런데 이 경로를 정당화하는 결정 문서가 없었고, 그 사이 ADR-0007은 `Accepted` 상태로 남아 코드와 정반대되는 내용을 현재 설계 근거처럼 제시하고 있었다.
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
1. **Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다.** MCP는 기동 preload와 주기 refresh에서 Portal registry를 조회한다.
|
||||||
|
2. **MCP 배포 하나가 N개 route를 서비스한다.** route key는 `/mcp/{routeKey}` URI에서만 결정한다.
|
||||||
|
3. **route 하나에 N개 Tool Service가 붙을 수 있다.** 카탈로그 병합 단위는 route다.
|
||||||
|
4. Portal은 **주소만** 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유하고, Tool 실행 주소도 매니페스트가 정한다([ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||||
|
5. 요청 경로(`tools/list`, `tools/call`, route key 검증)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다.
|
||||||
|
6. 응답 모양과 실패 처리는 [Portal-MCP 계약 v0.1](../contracts/portal-mcp/protocol-v0.1-registry.md)이 정본이다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
### 매핑이 동적이면 배포 축과 매핑 축을 겹칠 수 없다
|
||||||
|
|
||||||
|
ADR-0007은 매핑을 배포 정의에 넣었다. Portal이 매핑을 소유하는 순간 **매핑 변경이 곧 배포 변경**이 되어 Portal을 원천으로 둔 의미가 사라진다. 원천이 Portal이면 배포는 매핑에 대해 중립이어야 하고, 그래서 한 배포가 N route를 서비스한다.
|
||||||
|
|
||||||
|
### ADR-0007의 격리 논거는 층위별로 다르게 남는다
|
||||||
|
|
||||||
|
ADR-0007이 지키려던 것은 가용성 등급별 격리였다. 이 구조에서 무엇이 남고 무엇이 사라지는지 숨기지 않고 적는다. 아래는 현재 main 코드 기준이다.
|
||||||
|
|
||||||
|
| 층위 | 격리 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| route별 snapshot 보관 | **유지** | `ToolRegistryService`가 route별 snapshot을 따로 들고, 요청은 자기 route만 읽는다 |
|
||||||
|
| route 안 N개 Tool Service의 **조회** | **유지** | `ToolBundleDiscovery`가 bundle마다 last-good을 따로 보관한다 |
|
||||||
|
| route 안 카탈로그 **교체** | **없음** | 사용 가능한 성공본이 없는 Tool Service가 하나라도 있으면 그 route 전체 교체를 거부한다 |
|
||||||
|
| route 간 **갱신** | **유지** | `PortalToolRegistryClient.fetchAllTools()`가 route마다 `fetchRouteToolsSafely()`로 예외를 격리하고 실패한 route만 결과에서 뺀다 |
|
||||||
|
| 프로세스 자원(connection pool, thread, heap) | **없음** | 전 route가 공유한다 |
|
||||||
|
| 배포·재기동·프로세스 장애 | **없음** | 전 route가 동시에 영향을 받는다 |
|
||||||
|
|
||||||
|
**ADR-0007이 지키려던 가용성 등급별 물리 분리는 이 구조에서 성립하지 않는다.** 등급 요구가 다시 생기면 이 ADR을 재검토한다(전제 2).
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
아래가 깨지면 이 결정을 재검토한다.
|
||||||
|
|
||||||
|
1. route↔Tool Service 매핑의 관리 주체는 Portal이며, 매핑 변경이 MCP 재배포 없이 반영되어야 한다.
|
||||||
|
2. 가용성 등급별 물리 분리 요구가 없다.
|
||||||
|
3. 전 route의 Tool 총량과 매니페스트 조회 부하를 한 프로세스가 감당한다.
|
||||||
|
4. Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다.
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
- route 없는 `/mcp` 호출은 `route key is required`로 거부된다. Agent Builder에는 route별 URL만 등록한다.
|
||||||
|
- 등록되지 않은 route는 `McpRouteKeyValidator`가 controller 진입 전에 거부한다. 판단은 memory snapshot만 본다.
|
||||||
|
- Tool 이름 유일성은 **route 안에서만** 검사한다. 서로 다른 route에 같은 이름이 있어도 거부하지 않는다.
|
||||||
|
- `mcp.discovery.max-tools-total`은 전역이 아니라 **route 단위 상한**으로 동작한다. `merge()`가 route마다 호출되기 때문이다.
|
||||||
|
- Portal 조회 실패는 목록을 비우지 않는다. memory를 유지하고, cold start일 때만 `mcp.redis.portal-registry-key`의 Redis fallback을 읽는다.
|
||||||
|
- refresh 실패는 애플리케이션을 죽이지 않는다. `ToolRegistryRefreshScheduler`가 `RuntimeException`을 잡아 warn 로그만 남긴다.
|
||||||
|
- [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정 `publicPath` 대신 `/mcp` + 동적 route로 처리하므로 결정 4만 이 ADR이 대체한다.
|
||||||
|
- [ADR-0002](ADR-0002-tool-exposure-and-single-call.md)의 Tool 노출 상한 50개는 Agent 기준 합계이므로 바뀌지 않는다.
|
||||||
|
|
||||||
|
## 남은 위험
|
||||||
|
|
||||||
|
이 결정을 확정하면서 코드가 아직 따라오지 못한 지점이다. 둘 다 이 ADR의 의도와 어긋나므로 기록해 둔다.
|
||||||
|
|
||||||
|
처음 이 문서를 쓸 때 적었던 "route 간 갱신 격리 없음"은 `6653030 Isolate route manifest failures during tool preload`으로 해소되어 위 격리 표로 옮겼다.
|
||||||
|
|
||||||
|
1. **warm start가 Portal 모드에서 동작하지 않는다.** `ToolRegistryService.warmStartFromSharedCache()`는 route `""`의 Redis key만 읽는다. route가 이름을 갖는 이 구성에서는 아무것도 읽지 못해, 기동 직후 빈 목록 구간을 줄이는 효과가 사라진다.
|
||||||
|
2. **readiness가 route별 상태를 노출하지 않는다.** `ToolCatalogHealthIndicator`는 `usableSnapshot`만 detail로 내보낸다. 어느 route가 준비됐고 어느 route가 비어 있는지 관제가 알 수 없다.
|
||||||
|
|
||||||
|
## 남은 판단
|
||||||
|
|
||||||
|
- `deploy/helm/`의 배포별 topology와 `HelmDeploymentContractTest`는 `mcp.bundles` 기반 1:1 구성의 계약이다. 코드에 그 경로가 남아 있어 local 검증과 1:1 배포에서는 유효하지만, 내부망 운영 대상인지 여부는 이 ADR이 정하지 않는다.
|
||||||
|
- readiness를 route 단위로 세분화할지는 운영 관측 이후에 다시 본다.
|
||||||
|
|
||||||
|
## 채택하지 않은 대안
|
||||||
|
|
||||||
|
**ADR-0007을 유지하고 배포마다 자기 route만 조회한다.** 격리는 지키지만 route 추가가 배포 추가가 된다. Portal이 route 목록의 원천인데 배포 topology가 그 목록을 따라가야 하므로 순환이 생긴다.
|
||||||
|
|
||||||
|
**`mcp.bundles`에 매핑을 하드코딩한다.** 매핑 변경마다 재배포가 필요해 전제 1과 충돌한다.
|
||||||
|
|
||||||
|
**route별로 프로세스를 나누고 각자 Portal을 조회한다.** 자원 격리는 얻지만 Portal이 route 목록을 소유하는 이상 배포 수를 Portal이 정하게 되어, 운영 중 route 추가가 배포 파이프라인을 건드린다.
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
| [ADR-0004](ADR-0004-execution-guardrails.md) | 300초, Raw Data, unsafe retry 실행 가드레일 | Accepted |
|
| [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-0005](ADR-0005-standard-tool-name.md) | 표준 MCP Tool name을 실행 식별자로 사용 | Accepted |
|
||||||
| [ADR-0006](ADR-0006-no-authentication-in-mcp.md) | MCP Server는 인증·인가를 하지 않는다 | 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-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-0009](ADR-0009-container-handles-public-mcp-path.md) | 컨테이너가 공개 MCP path를 직접 처리 | Accepted |
|
||||||
|
| [ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md) | Tool 실행 endpoint를 Tool Service 매니페스트가 선언 | Accepted |
|
||||||
| [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) | Tool inputSchema는 문서 밖을 참조하지 않는다 | Accepted |
|
| [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) | Tool inputSchema는 문서 밖을 참조하지 않는다 | Accepted |
|
||||||
| [ADR-0012](ADR-0012-tool-input-schema-pattern-budget.md) | Tool inputSchema의 정규식에 예산을 둔다 | Accepted |
|
| [ADR-0012](ADR-0012-tool-input-schema-pattern-budget.md) | Tool inputSchema의 정규식에 예산을 둔다 | Accepted |
|
||||||
|
| [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md) | Tool Server endpoint 목록과 route 매핑의 원천은 Portal | Accepted |
|
||||||
|
|||||||
Reference in New Issue
Block a user