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>
9.7 KiB
ADR-0013 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다
- 상태: Accepted
- 결정일: 2026-08-16
- 대체 결정: ADR-0007 전체, ADR-0009 결정 4
- 관련: ADR-0001 · ADR-0005 · Portal-MCP 계약 v0.1
배경
ADR-0007은 MCP 배포 하나가 Tool Service 하나만 보게 하고 mcp.bundles를 배포 설정에 선언했다.
그 전제는 어떤 Tool Service를 볼지가 배포 시점에 확정된다는 것이었다.
내부망 운영은 그 전제를 따르지 않기로 했다. route와 Tool Service의 매핑은 Portal이 관리하고, MCP는 기동할 때 Portal API에서 route 정보·Tool Service endpoint·매핑 관계를 받아 온다. 매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.
결정
- Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다. MCP는 기동 preload와 주기 refresh에서 Portal registry API를 조회한다.
mcp.bundles는 비운다. - MCP 배포 하나가 N개 route를 서비스한다. route key는
/mcp/{routeKey}URI에서 결정하며 설정 기본값으로 보정하지 않는다. - route 하나에 N개 Tool Service가 붙을 수 있다. 카탈로그 병합 단위는 route다.
- Portal은 주소만 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유한다.
- 요청 경로(
tools/list,tools/call)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다. - 요청·응답 모양과 실패 처리는 Portal-MCP 계약 v0.1이 정본이다.
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).
전제
아래가 깨지면 이 결정을 재검토한다.
- route↔Tool Service 매핑의 관리 주체는 Portal이며, 매핑 변경이 MCP 재배포 없이 반영되어야 한다.
- 가용성 등급별 물리 분리 요구가 없다.
- 전 route의 Tool 총량과 매니페스트 조회 부하를 한 프로세스가 감당한다.
- Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다(계약 §8).
영향
실패 전파 범위를 route 단위로 잠갔다. 계약 v0.2 §1의 "aggregate는 전부 아니면 전무"는 카탈로그 하나를 온전히 유지하기 위한 규칙이다. 1:1 구조에서는 카탈로그 하나가 곧 route 하나였으므로 범위가 같았다. route가 N개가 되면서 같은 코드가 "전 route 전부 아니면 전무"로 확대됐고, 이는 의도된 것이 아니었다. 이 결정과 함께 다음을 적용한다.
PortalToolRegistryClient.fetchAllTools()는 route마다 예외를 격리하고 실패한 route만 결과에서 제외한다.ToolRegistryClient.knownRoutes()가 원천이 선언한 route 집합을 제공하고,ToolRegistryService.refreshKnownRoutes()는 제거 판단을 이 집합으로만 한다. 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라져 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의 Tool 노출 상한 50개는 Agent 기준 합계이므로 바뀌지 않는다.
- ADR-0009의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정
publicPath대신/mcp+ 동적 route로 처리한다.
후속 조치
이 ADR과 함께 정리한 항목이다. 남은 판단이 있는 것만 적는다.
- warm start를 route별로 확장했다.
warmStartFromSharedCache()가 원천이 선언한 route마다 Redis last-good을 읽는다. 읽을 key를 알려면 route 목록이 먼저 있어야 하므로 기동 preload 순서를registry 조회 → warm start → manifest 조회로 바꿨다. mcp.portal.route-key를 제거했다. 어떤 코드도 읽지 않았고, route key는 요청 URI에서만 결정된다. 설정으로 기본 route를 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.- Helm chart는 유지하되 적용 범위를 명시했다.
mcp.bundles구성이 코드에 그대로 남아 있고 local 검증과 1:1 배포 환경에서 유효하므로 삭제하지 않는다. 내부망 운영 대상이 아니라는 사실을deploy/README.md와values.yaml머리말에 적었다.HelmDeploymentContractTest는 그 구성의 계약으로 계속 유효하다. 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 추가가 배포 파이프라인을 건드린다.