# Portal-MCP Registry 조회 계약 v0.1 - 상태: **MCP 측 구현 완료, Portal 측 미합의** - 기준일: 2026-08-14 - 조회 endpoint: `GET {mcp.portal.registry-url}` — Portal이 제공 - 구현: `PortalToolRegistryClient` (`@ConditionalOnProperty(mcp.portal.enabled=true)`) ## 1. 계약 범위와 원칙 Portal은 route별로 **어떤 Tool Server가 있고 그 주소가 무엇인지**를 관리한다. MCP는 이 목록을 주기적으로 조회해 Tool Service 매니페스트 조회 대상을 결정한다. | 원칙 | 내용 | |---|---| | Portal은 주소만 말한다 | Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유한다. Portal 응답에는 Tool 정의가 없다 | | MCP가 가져온다 | Portal은 제공만 한다. MCP에 push하지 않으며 MCP는 쓰기 endpoint를 열지 않는다 | | 응답은 전체 상태 | 증분이 없다. 응답에 없는 route는 memory에서 제거된다(§6) | | 조회 주기가 분리된다 | Portal registry와 Tool 매니페스트는 서로 다른 주기로 조회한다(§6) | | 실패는 삭제가 아니다 | 어떤 실패도 endpoint 목록이나 Tool snapshot을 비우지 않는다(§7) | | 요청 경로는 Portal을 모른다 | `tools/list`·`tools/call`은 in-memory snapshot만 읽는다 | `mcp.bundles`를 쓰는 구성과의 차이는 **하나뿐**이다. Tool Server 주소가 배포 YAML에서 오느냐 Portal에서 오느냐. 주소를 확보한 다음의 매니페스트 조회·검증·병합은 [tool-service-mcp v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md)를 그대로 재사용한다. ## 2. Portal이 제공하는 endpoint | Method | Path | 용도 | MCP가 호출하는가 | |---|---|---|---| | `GET` | `/api/portal/registry` | 전체 route 집계 조회 | **예. 유일한 호출 대상** | | `GET` | `/api/portal/registry/{routeKey}` | 단일 route 조회 | 아니오 (§5 참고) | MCP는 `mcp.portal.registry-url`에 설정된 **하나의 URL만** 호출한다. route별로 나눠 호출하지 않는다. 따라서 **`registry-url`은 집계 endpoint를 가리켜야 한다.** > `registry-url`에 `{route}` placeholder를 쓸 수 있게 되어 있으나, > 현재 구현은 registry 갱신 시 `{route}`를 **항상 빈 문자열로** 치환한다(`registryUrl("")`). > 즉 `/api/portal/registry/{route}` 형태로 설정하면 `/api/portal/registry/`를 호출해 실패한다. > **placeholder를 쓰지 않는다.** 단일 route 조회 endpoint는 Portal 화면과 운영 확인용으로 남아 있으며 MCP 경로가 아니다. 다만 응답 shape는 MCP가 파싱할 수 있는 형태를 유지한다(§4.2). 이유는 §4.3에 있다. ## 3. MCP 설정 (YAML) 예시는 [mcp-portal-config.yaml](examples/registry-v0.1/mcp-portal-config.yaml)에 있다. ```yaml mcp: portal: enabled: true registry-url: http://portal.ax-hub.svc.cluster.local:8080/api/portal/registry refresh-interval-seconds: 300 discovery: enabled: true bundles: [] ``` | 항목 | 필수 | 설명 | |---|---|---| | `mcp.portal.enabled` | 예 | `true`일 때만 `PortalToolRegistryClient`가 등록된다. `false`면 `mcp.bundles`를 사용한다 | | `mcp.portal.registry-url` | `enabled=true`일 때 예 | 집계 조회 URL. 누락 시 기동이 실패한다(`McpProperties.isPortalTargetDeclared`) | | `mcp.portal.refresh-interval-seconds` | 아니오(기본 300) | Portal registry 조회 주기 | | `mcp.redis.portal-registry-key` | 아니오 | Portal registry fallback Redis key. 기본값은 `{key-prefix}:portal-registry` | `mcp.portal.enabled=true`이면 `mcp.bundles`는 비운다. endpoint 원천이 둘이 되지 않게 한다. > route key를 지정하는 설정은 **없다.** route key는 요청 경로에서만 결정되며(`McpRequestContextFactory`), > 설정 기본값으로 보정하지 않는다(§7). 과거 `mcp.portal.route-key`가 선언만 되어 있었으나 > 어떤 코드도 읽지 않아 제거했다([ADR-0010](../../decisions/ADR-0010-portal-owns-route-and-endpoint-registry.md)). ## 4. 응답 계약 ### 4.1 집계 응답 (MCP가 사용하는 형태) 예제: [aggregate-registry-response.json](examples/registry-v0.1/aggregate-registry-response.json) ```json { "registryRevision": 12, "routes": [ { "routeKey": "external", "toolServices": [ /* §4.4 */ ] } ] } ``` | 필드 | 필수 | 타입 | 의미 | |---|---|---|---| | `registryRevision` | 아니오 | number 또는 string | 변경 감지용 판. §6 | | `routes` | 예 | array | route 전체 목록. 이 배열이 있으면 집계 응답으로 해석한다 | | `routes[].routeKey` | **예** | string | 비어 있으면 registry 오류. 공백은 trim된다 | | `routes[].toolServices` | 예 | array | 해당 route의 Tool Server 목록. §4.4 | ### 4.2 단일 route 응답 예제: [route-registry-response.json](examples/registry-v0.1/route-registry-response.json) ```json { "routeKey": "external", "registryRevision": 12, "toolServices": [ /* §4.4 */ ] } ``` | 필드 | 필수 | 의미 | |---|---|---| | `routeKey` | **예** | 최상위에 있어야 한다 | | `toolServices` | 예 | §4.4 | ### 4.3 두 형태를 모두 받는 이유와 그 위험 MCP는 응답에 `routes` 배열이 **없으면** 단일 route 문서로 해석해 최상위 `routeKey`를 읽는다. 이 관용은 Redis fallback에 저장된 과거 형태를 읽기 위한 것이다. **두 형태의 삭제 의미가 다르다.** | 응답 형태 | memory 반영 | |---|---| | 집계(`routes` 있음) | 응답에 없는 route를 **제거**한다. 전체 상태 교체 | | 단일(`routes` 없음) | 그 route만 **덮어쓴다**. 다른 route는 남는다 | 따라서 운영에서 Portal은 **항상 집계 형태로 응답한다.** 단일 형태를 정기 조회 대상으로 쓰면 Portal에서 삭제한 route가 MCP memory에 영원히 남는다. ### 4.4 `toolServices[]` 항목 | 필드 | 필수 | 기본값 | MCP가 만드는 값 | |---|---|---|---| | `serviceKey` | **예** | — | bundle id. 매니페스트의 `bundleId`와 일치해야 한다 | | `serviceDomain` | **예** | — | scheme+host+port. 끝 `/`는 제거된다 | | `manifestPath` | **예** | — | `manifestUrl = serviceDomain + manifestPath`. 앞 `/`가 없으면 붙인다 | | `executeBasePath` | 아니오 | `""` | `baseEndpoint = serviceDomain + executeBasePath`. 앞뒤 `/`가 정규화된다 | | `namePrefix` | 아니오 | `""` | Tool name 접두사 검증 기준 | | `toolEndpoints` | 아니오 | `{}` | Tool name → 실행 path. 값은 앞 `/`가 보장되도록 정규화된다 | | `status` | 아니오 | `"ACTIVE"` | `ACTIVE`가 아니면 **조용히 제외**한다. 대소문자 무시 | | `displayName` | 아니오 | — | Portal 화면용. **MCP는 무시한다** | - 필수 필드가 없거나 공백이면 registry 오류다. 오류 메시지에는 필드명만 남기고 응답 원문은 넣지 않는다. - **ACTIVE 서비스가 하나도 없으면 그 응답 전체를 실패로 처리한다.** 빈 목록으로 교체하지 않는다. - `toolEndpoints`가 비면 실행 주소는 `baseEndpoint`에 Tool name을 붙이는 기존 계약을 따른다. ## 5. Portal이 응답에 넣지 않는 것 | 넣지 않는 것 | 이유 | |---|---| | Tool 정의(name, schema, timeout) | Tool Service 매니페스트가 정본이다 | | 매니페스트 조회용 API key | §9의 미확정 항목. 현재 MCP는 자기 설정의 key를 쓴다 | | MCP 자신의 endpoint 주소 | MCP가 자기 주소를 Portal에서 받지 않는다 | ## 6. 조회 주기와 변경 감지 | 주기 | 대상 | 설정 | |---|---|---| | 기동 preload | Portal registry → 각 Tool Service 매니페스트 | 즉시 | | `mcp.portal.refresh-interval-seconds` | Portal registry만 | 기본 300초 | | `mcp.registry.refresh-interval-seconds` | 저장된 endpoint의 매니페스트만 | 기본 30초 | `registryRevision`이 직전과 다르면 MCP는 그 응답 전체를 INFO 로그로 남기고, **즉시 매니페스트 refresh를 한 번 더 트리거한다**(`ToolRegistryRefreshScheduler`의 `portal-change`). Portal에서 endpoint를 바꾼 뒤 매니페스트 주기를 기다리지 않게 하기 위한 것이다. `registryRevision`은 **변경 감지에만** 쓴다. 순서 비교를 하지 않으므로 값이 되돌아가도 "변경됨"으로 처리한다. 단조 증가는 Portal이 보장할 항목이다(README 확정 항목 3). > 이 로그는 응답 JSON 전체를 출력한다. registry 응답에는 credential이 없으나 > **내부 endpoint 주소가 그대로 남는다.** 폐쇄망 운영 로그 정책에서 확인이 필요하다. ## 7. 실패 처리 모든 registry 실패는 JSON-RPC `TOOL_REGISTRY_UNAVAILABLE`로 변환된다. | 상황 | 동작 | |---|---| | Portal 조회 실패 + memory에 endpoint 있음 | **memory 유지.** Redis를 읽지 않는다. WARN 로그 | | Portal 조회 실패 + memory 비어 있음(cold start) | `mcp.redis.portal-registry-key`의 registry JSON을 fallback으로 읽는다 | | Portal·Redis 모두 실패 | 실패로 처리하고 다음 주기에 재시도. 목록은 비우지 않는다 | | 요청 route가 memory에 없음 | `Portal registry route is not found: {routeKey}` | | 요청 route key가 공백 | `Portal registry routeKey is required`. **설정 기본 route로 보정하지 않는다** | | ACTIVE 서비스 없음 | `Portal registry has no active Tool Service` | | 직전 성공본조차 없는 Tool Service가 있음 | 카탈로그 전체를 교체하지 않는다 | | Tool name 중복 (서비스 간) | 교체하지 않는다 | | `mcp.discovery.max-tools-total` 초과 | 교체하지 않는다 | 마지막 세 항목은 tool-service-mcp v0.2의 병합 규칙을 그대로 따른다. route key를 보정하지 않는 것은 **잘못된 단일 진입점 호출을 조용히 성공시키지 않기 위한 것**이다. Redis fallback은 두 종류이며 key가 분리된다. | key | 내용 | 언제 | |---|---|---| | `mcp.redis.portal-registry-key` | Portal registry 응답 JSON | route·endpoint 목록 자체를 모를 때 | | `{key-prefix}:{identity}:v2:route:{routeToken}` | route별 Tool snapshot | 이미 아는 route의 마지막 Tool 목록 | ## 8. 보안 요구사항 `mcp.bundles` 구성에서 [AGENTS.md](../../../AGENTS.md)의 불변식은 "outbound 주소는 설정에서만 온다"이다. **Portal 구성에서는 그 원천이 Portal로 옮겨간다.** 따라서 이 계약은 다음을 요구한다. 1. **Portal registry API는 공개 네트워크에 노출하지 않는다.** MCP와 Portal 사이는 NetworkPolicy로 제한한다. 2. **Portal의 쓰기 API(bundle 등록·수정)는 인증을 요구한다.** 이 API를 장악하면 MCP의 호출 대상을 바꿀 수 있다. 3. Tool Service 매니페스트는 여전히 호출 대상을 바꾸지 못한다. 매니페스트는 `serviceDomain`을 덮어쓸 수 없다. 1·2를 만족하지 못하는 환경에서는 Portal 구성을 쓰지 않고 `mcp.bundles`를 쓴다. ## 9. 미확정 항목 | 항목 | 현재 | 확정 필요 | |---|---|---| | Portal API 인증 | 없음 | 방식과 credential 관리 주체 | | 매니페스트 조회 credential | MCP 설정의 `mcp.tool-client.api-key` 단일 값 | Tool Service별로 다를 때 전달 경로. registry 응답에 담을지 여부 | | `registryRevision` 채번 | Portal in-memory 카운터 | 재기동 시 유지 여부, 단조 증가 보장 | | route 삭제 | 집계 응답에서 사라지면 즉시 제거 | 진행 중 요청에 대한 rolling 처리 | ## 10. 예제와 검증 | 파일 | 용도 | |---|---| | [aggregate-registry-response.json](examples/registry-v0.1/aggregate-registry-response.json) | 운영에서 MCP가 받는 형태 | | [route-registry-response.json](examples/registry-v0.1/route-registry-response.json) | 단일 route 형태 | | [mcp-portal-config.yaml](examples/registry-v0.1/mcp-portal-config.yaml) | MCP 설정 예시 | 앞의 두 JSON은 `PortalRegistryContractExampleTest`가 읽어 `PortalToolRegistryClient`의 실제 파싱 경로에 태운다. `serviceDomain`만 테스트가 MockWebServer 주소로 치환하며, 나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**