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

8.4 KiB

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

  • 상태: Accepted
  • 결정일: 2026-08-22
  • 대체 결정: ADR-0007 전체, ADR-0009 결정 4
  • 관련: ADR-0001 · ADR-0005 · ADR-0010 · Portal-MCP 계약 v0.1
  • 번호 주석: feature/mcp-integration이 ADR-0011·ADR-0012를 Tool inputSchema 정책에 쓰고 있어 그 번호를 비워 둔다. 같은 브랜치의 동명 ADR-0013과 이 문서는 같은 결정이다.

배경

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

남은 판단

  • mcp.portal.route-key가 선언돼 있으나 어떤 코드도 읽지 않는다. 결정 2에 따라 route는 URI에서만 오므로 이 속성은 제거 대상이다. 지금은 선언만 남아 있다.
  • 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 추가가 배포 파이프라인을 건드린다.