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>
12 KiB
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를 그대로 재사용한다.
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에 있다.
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-0013).
4. 응답 계약
4.1 집계 응답 (MCP가 사용하는 형태)
예제: aggregate-registry-response.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
{
"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의 불변식은
"outbound 주소는 설정에서만 온다"이다. Portal 구성에서는 그 원천이 Portal로 옮겨간다.
따라서 이 계약은 다음을 요구한다.
- Portal registry API는 공개 네트워크에 노출하지 않는다. MCP와 Portal 사이는 NetworkPolicy로 제한한다.
- Portal의 쓰기 API(bundle 등록·수정)는 인증을 요구한다. 이 API를 장악하면 MCP의 호출 대상을 바꿀 수 있다.
- 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 | 운영에서 MCP가 받는 형태 |
| route-registry-response.json | 단일 route 형태 |
| mcp-portal-config.yaml | MCP 설정 예시 |
앞의 두 JSON은 PortalRegistryContractExampleTest가 읽어 PortalToolRegistryClient의
실제 파싱 경로에 태운다. serviceDomain만 테스트가 MockWebServer 주소로 치환하며,
나머지 필드는 파일 그대로 사용한다. 예제를 고치면 이 테스트가 함께 깨져야 한다.