Files
dap-was-dapms/docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md
koseokmin cb29b192b4 docs를 저장소로 되돌리고 계약 예제를 복원한다
5cfb8a1이 .gitignore에 docs/를 넣고 68개 파일을 지웠다. 그런데
AgentBuilderContractExampleTest, ToolBundleContractExampleTest,
ArchitectureDocumentContractTest는 docs/ 아래 계약 예제와 architecture
문서를 입력으로 직접 읽는다. 그 결과 clean clone에서 테스트 10건이
입력을 찾지 못해 실패했다.

제외 범위를 원래 의도대로 좁힌다. 에이전트 산출물(AGENTS.md, .agents/,
.codex/, docs/superpowers/)은 계속 제외하고 저장소 문서는 추적한다.

문서는 삭제 직전 상태(3de052a)를 기준으로 복원하고, 그 위에 main 코드와
대조해 어긋난 부분을 고쳤다.

- ADR-0007을 Superseded로 바꾸고 ADR-0013을 새로 쓴다. route당 Tool
  Service N개가 최종안이며, PortalToolRegistryClient가 이미 route별로
  N개를 유지하고 있는데 ADR-0007은 "bundles는 항상 한 항목"을 Accepted
  상태로 주장하고 있었다. ADR-0009 결정 4도 부분 대체한다.
- ADR-0008 파일 헤더가 Accepted였으나 ADR-0009가 이미 대체한 상태였다.
- 6078852의 endpoint 소유권 반전이 반영되지 않은 서술을 계약 v0.2,
  bundle 설정 예제, Tool 적재 안내에서 고친다.
- Portal registry 계약 v0.1과 route key 규약을 새로 문서화한다. 둘 다
  구현은 있는데 계약 문서가 없었다.
- MCP SDK 2.0.0 SBOM을 추가한다.

번호 주석: ADR-0011과 0012는 feature/mcp-integration이 Tool inputSchema
정책에 쓰고 있어 비워 둔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:27:56 +09:00

88 lines
8.4 KiB
Markdown

# 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)
- 번호 주석: `feature/mcp-integration`이 ADR-0011·ADR-0012를 Tool inputSchema 정책에 쓰고 있어 그 번호를 비워 둔다. 같은 브랜치의 동명 ADR-0013과 이 문서는 같은 결정이다.
## 배경
[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 간 **갱신** | **없음** | 아래 "남은 위험" 1번 참고. 한 route의 실패가 그 주기의 전 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의 의도와 어긋나므로 기록해 둔다.
1. **route 간 갱신 격리가 없다.** `PortalToolRegistryClient.fetchAllTools()``bundlesByRoute`를 순회하며 route마다 `merge()`를 호출하는데, 한 route가 `TOOL_REGISTRY_UNAVAILABLE`을 던지면 예외가 `fetchAllTools()` 밖으로 나가 그 주기의 갱신이 통째로 중단된다. 기존 snapshot은 남으므로 목록이 비지는 않지만, **Tool Service 하나의 장애가 무관한 route의 Tool 변경 반영까지 막는다.** route 단위로 예외를 격리하고 실패한 route만 결과에서 빼야 한다.
2. **warm start가 Portal 모드에서 동작하지 않는다.** `ToolRegistryService.warmStartFromSharedCache()`는 route `""`의 Redis key만 읽는다. route가 이름을 갖는 이 구성에서는 아무것도 읽지 못해, 기동 직후 빈 목록 구간을 줄이는 효과가 사라진다.
3. **readiness가 route별 상태를 노출하지 않는다.** `ToolCatalogHealthIndicator``usableSnapshot`만 detail로 내보낸다. 어느 route가 준비됐고 어느 route가 비어 있는지 관제가 알 수 없다.
## 남은 판단
- `mcp.portal.route-key`가 선언돼 있으나 어떤 코드도 읽지 않는다. 결정 2에 따라 route는 URI에서만 오므로 이 속성은 제거 대상이다. 지금은 선언만 남아 있다.
- `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 추가가 배포 파이프라인을 건드린다.