main이 6078852의 endpoint 소유권 반전을 ADR-0010으로 기록하면서 이 브랜치의 ADR-0010과 번호가 겹쳤다. 두 문서는 다른 결정이므로 나중에 문서를 합칠 때 한쪽을 옮겨야 한다. 이 브랜치가 0012까지 쓰고 있어 0011·0012를 건드리지 않는 첫 번호인 0013을 쓴다. 파일명과 제목, 대체 관계를 가리키는 ADR-0007·ADR-0009, 결정 목록, architecture 문서, Portal 계약 문서, Helm 설명, 그리고 application.yml 주석의 참조를 함께 바꾼다. 결정 내용은 바뀌지 않는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
9.7 KiB
Markdown
137 lines
9.7 KiB
Markdown
# ADR-0013 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 추가가 배포 파이프라인을 건드린다.
|