Files
dap-was-dapms/docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md
koseokmin e8ed554351 ADR-0013에서 잘못 지운 route-key 항목을 되살린다
4e4c341에서 mcp.portal.route-key가 main에 없다고 보고 "남은 판단"의
해당 항목을 삭제했다. 근거로 삼은 grep이 YAML 키만 봤고
McpProperties.Portal의 record 컴포넌트를 놓친 오판이었다.

routeKey 컴포넌트는 McpProperties.java:266에 그대로 있고, .portal()
호출 다섯 곳 중 routeKey()를 읽는 곳은 없다. 원래 항목이 맞았으므로
확인한 근거를 덧붙여 되살린다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:36:44 +09:00

8.1 KiB

ADR-0013 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다

배경

ADR-0007은 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).
  5. 요청 경로(tools/list, tools/call, route key 검증)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다.
  6. 응답 모양과 실패 처리는 Portal-MCP 계약 v0.1이 정본이다.

근거

매핑이 동적이면 배포 축과 매핑 축을 겹칠 수 없다

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 실패는 애플리케이션을 죽이지 않는다. ToolRegistryRefreshSchedulerRuntimeException을 잡아 warn 로그만 남긴다.
  • ADR-0009의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정 publicPath 대신 /mcp + 동적 route로 처리하므로 결정 4만 이 ADR이 대체한다.
  • ADR-0002의 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별 상태를 노출하지 않는다. ToolCatalogHealthIndicatorusableSnapshot만 detail로 내보낸다. 어느 route가 준비됐고 어느 route가 비어 있는지 관제가 알 수 없다.

남은 판단

  • McpProperties.PortalrouteKey 컴포넌트가 선언돼 있으나 어떤 코드도 읽지 않는다. application.ymlroute-key 키도 없고, .portal() 호출 다섯 곳 중 routeKey()를 읽는 곳이 없다. 결정 2에 따라 route는 URI에서만 오므로 이 컴포넌트는 제거 대상이다. 설정으로 기본 route를 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.
  • deploy/helm/의 배포별 topology와 HelmDeploymentContractTestmcp.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 추가가 배포 파이프라인을 건드린다.