# 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 추가가 배포 파이프라인을 건드린다.