9 Commits

Author SHA1 Message Date
4f60f4f01b GitOps 이전까지 쓸 배포 파이프라인과 Portal 모드 Chart를 추가한다
GitOps 저장소도 ArgoCD Application도 아직 없어, 그때까지 이 저장소가
push 방식 파이프라인(.gitea/workflows/)을 임시로 소유한다. 무엇을
포기하는지와 넘길 때 할 일은 deploy/README.md에 적었다.

Chart는 portal과 bundles 두 배포 모델을 모두 렌더링한다. ADR-0013이
ADR-0007을 대체했으므로 운영은 portal이 기준이지만, bundles 경로를
언제 삭제할지는 아직 정하지 않았다.

- .gitea/workflows/ci.yaml, deploy-openshift.yaml
- deploy/ci/render-manifests.sh, deploy/examples/
- Chart: mode 분기, selectedDeployment/tier helper, imagePullSecrets,
  toolService.apiKeySecret 참조
- extension-points.md에 미결 항목 9~12 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:03:04 +09:00
37fc0d5ebe 사원 식별자를 암호화 전제 없이 불투명 값으로 서술한다
README와 계약 v0.3이 employee-no·virtual-employee-no를 "암호화된 사원번호"로
적고 MCP가 복호화하지 않는다고 설명했다. 암호화 여부는 MCP가 확인할 수 없고
계약이 요구하는 것도 아니므로, MCP 관점에서 참인 것만 남긴다. 해석하지 않고
그대로 전달하는 불투명 값이라는 사실이다.

로그 규칙은 그대로 둔다. 로그에는 guid와 x-request-id만 남기고 사원 식별자는
기록하지 않는다.

IntelliJ 마크다운 포맷터가 표 정렬과 줄바꿈을 함께 정리해 diff가 크다.
서식 외의 실제 변경은 위 두 가지와 운영 설정 절의 미정 표기 추가다.

남은 정리: ADR-0006은 전제 2에서 여전히 "암호화되어 전달되고 복호화 키는
사내 KMS에서 발급받는다"고 적고 있어 이 변경과 어긋난다. ADR의 전제를 바꾸는
것은 별도 결정이므로 이 커밋에 포함하지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:32:12 +09:00
d31e5ce0ab SBOM의 적용 범위를 MCP와 Tool Service 공통으로 넓힌다
MCP Server와 Tool Service가 같은 스택과 같은 MCP SDK를 쓰므로 SBOM을
공통으로 적용한다. 산출물 이름을 AXHUB_MCP_Tool_Service_SBOM으로 바꾸고,
목록이 MCP Server 빌드 하나에서 나왔다는 한계를 함께 적는다. Tool Service가
스택 밖 의존을 추가하면 그 빌드에서 다시 산출해 합쳐야 한다.

JDK 라이선스를 미확정에서 GPL-2.0 with Classpath Exception으로 확정한다.
toolchain에 vendor를 고정하면서 배포판이 정해졌기 때문이다. Classpath
Exception이 있어 이 JDK로 실행하는 애플리케이션에 소스 공개 의무는 없다.

inputSchema 관련 보안 통제 설명은 SBOM의 범위가 아니므로 뺀다. 근거는
ADR-0011과 ADR-0012가 소유한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:31:54 +09:00
7afb4d9e05 toolchain을 Eclipse Temurin으로 고정한다
vendor를 적지 않으면 설치된 아무 JDK 21이나 잡히므로, 표준가이드가 정한
배포판(openjdk21u-jdk_x64_windows_hotspot_21.0.5)과 다른 것으로 조용히
빌드될 수 있다. SBOM의 JDK 항목도 그 전제 위에 있다.

폐쇄망에서는 toolchain 자동 다운로드가 동작하지 않으므로 빌드 머신에
Temurin이 미리 설치되어 있어야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:31:40 +09:00
5c069a0ee7 Portal registry ADR의 번호를 0013으로 옮긴다
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>
2026-08-22 21:23:29 +09:00
6424f25917 MCP Java SDK 2.0.0의 SBOM을 CycloneDX 1.5로 추가한다
build.gradle이 직접 선언한 오픈소스는 mcp-json-jackson2 하나지만, 런타임
전이 의존까지 따라가면 12건이 산출물 classpath에 올라간다. json-schema-validator,
itu, jackson-dataformat-yaml, reactor-core, reactive-streams는 SDK를 넣기
전에는 없던 것들이라 함께 수록한다. 여기에 빌드 환경 2건(JDK 21, Gradle
8.14.3)을 scope optional로 더해 14건이다.

버전과 해시는 Gradle 로컬 캐시의 실제 pom을 따라가 그래프를 만들고 실제 jar
바이너리에서 SHA-512/SHA-1을 계산했다. Gradle 배포본의 SHA-256은 wrapper의
distributionSha256Sum 값이다. 산출 근거와 한계는 docs/sbom/README.md에 있다.

라이선스는 Apache-2.0 9건, MIT 3건, MIT-0 1건, JDK 미확정 1건이다. copyleft가
없어 소스 공개 의무는 없다. JDK는 toolchain이 벤더를 고정하지 않으므로 실제
배포판이 정해지면 라이선스를 확정해야 한다.

optional인 joni/graal-js/graal-sdk(약 50MB), provided인 jakarta.servlet-api,
test scope와 annotationProcessor는 산출물에 포함되지 않아 제외했다. 제외 사유는
Exclusions 시트에 있다.

확인이 남은 항목은 jackson-databind다. SDK가 요청한 2.20.1이
io.spring.dependency-management에 의해 Spring Boot 3.5.11의 2.19.4로 내려간다.
sandbox에서 gradle daemon이 뜨지 않아 gradlew dependencies로 대조하지 못했으므로
빌드 환경에서 한 번 확인해야 한다.

xlsx가 줄바꿈 정규화 대상이 되지 않도록 .gitattributes에 binary로 명시한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:45:38 +09:00
d64516b50a Tool inputSchema의 문서 밖 참조와 정규식 폭증을 수신 시점에 차단한다
MCP Java SDK 도입으로 들어온 json-schema-validator는 schema의 $ref가 문서
밖을 가리키면 그 주소로 직접 조회하고, pattern 검증을 백트래킹 기반
java.util.regex로 처리한다. inputSchema는 Tool Service 매니페스트에서 오므로
매니페스트가 서버의 outbound 대상과 CPU 소비를 정할 수 있었다. AGENTS.md의
"outbound 주소는 설정에서만 온다"는 불변식이 이 경로에서 뚫려 있었다.

DefaultJsonSchemaValidator는 SchemaRegistry를 생성자 안에서 만들고 private
final로 들고 있어 정책 주입 지점이 없다. 따라서 SDK 밖에서만 막을 수 있다.

검사는 ToolMetadata의 표준 생성자에 둔다. Portal 매니페스트, local 파일,
Redis snapshot 역직렬화가 모두 이 생성자를 지나므로 우회 경로가 생기지 않는다.
위반은 기존 매니페스트 형식 오류와 같게 다뤄 bundle 단위 실패 격리와
"Redis 실패는 언제나 cache miss" 동작을 그대로 유지한다.

정규식 규칙은 JDK 21.0.11 실측으로 정했다. 통념과 달리 (a+)+는 빠르게 끝나고,
중첩이 아닌 a*a*a*a*a*b와 바깥 반복이 유한한 (.*,){11}P가 폭증했다. 겹치는
문자 집합 판정은 결정 불가능하므로 모양 검사만으로는 부족하고, pattern 필드에
maxLength 동반 선언을 요구해 입력 길이를 묶는 것이 실질적인 상한이 된다.
patternProperties는 key에 길이를 선언할 자리가 없어 사용을 금지한다.

format은 단언되지 않아 format:regex 경로가 실행되지 않는다는 사실도 계약
테스트로 고정했다. SDK 업그레이드로 단언이 켜지면 테스트가 실패한다.

Tool Service는 pattern을 쓰는 필드에 maxLength(<=256)를 선언해야 하므로
매니페스트 수용 조건이 바뀐다. Tool Service 파트와 합의가 필요하다.

근거: ADR-0011, ADR-0012

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:44:01 +09:00
76ed199499 에이전트 도구의 로컬 캐시를 gitignore에 추가한다
.ua/는 understand 스킬의 knowledge-graph 캐시이고 skills-lock.json은
스킬 매니저가 만드는 잠금 파일이다. 둘 다 로컬 도구 산출물이라
저장소가 소유할 대상이 아닌데 매번 untracked로 남아 status를 흐렸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:38:36 +09:00
1e6fa8f22f Portal을 endpoint 원천으로 확정하고 route 단위 실패 격리를 적용한다
내부망 운영은 route↔Tool Service 매핑을 Portal이 소유하고, MCP 배포 하나가 N개
route를 서비스하며, route 하나에 N개 Tool Service가 붙을 수 있다. 이 판단을
ADR-0010으로 남기고 ADR-0007 전체와 ADR-0009 결정 4를 대체한다.

계약
- Portal-MCP registry 조회 계약 v0.1과 예제 JSON을 docs/contracts/portal-mcp/에
  신설한다. 지금까지 이 경로에는 정본이 없었다.
- 예제를 PortalToolRegistryClient의 실제 파싱 경로에 태우는 계약 테스트를 추가해
  문서와 구현이 따로 표류하지 않게 한다.

실패 격리
- fetchAllTools()의 실패 전파를 route 단위로 격리한다. 계약 v0.2의 "aggregate는
  전부 아니면 전무"는 카탈로그 하나를 전제한 규칙인데, route가 N개가 되면서 전
  route로 확대돼 있었다. Tool Service 하나의 장애가 cold start에서 Pod 전체를
  내리고 steady state에서 모든 route의 갱신을 멈추던 동작을 없앤다.
- 제거 판단의 원천을 ToolRegistryClient.knownRoutes()로 분리한다. 조회 결과를
  기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라져 "어떤
  실패도 목록을 비우지 않는다" 불변식이 깨진다.
- readiness는 최소 1개 route로 UP을 유지한다. 모든 route를 요구하면 정상 route까지
  트래픽에서 빠져 위 격리를 되돌리기 때문이다. 대신 routesWithoutSnapshot을
  health detail로 노출해 관제가 부분 상태를 감지하게 한다.

설정과 기동
- warm start가 route별 Redis key를 읽도록 확장하고, 읽을 key를 알기 위해 기동
  preload 순서를 registry 조회 → warm start → manifest 조회로 바꾼다.
- 어떤 코드도 읽지 않던 mcp.portal.route-key를 제거한다. route key는 요청 URI에서만
  결정되며, 설정으로 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.

정리
- ToolRegistryService.java의 이중 인코딩으로 깨져 있던 한글 Javadoc 33줄을 코드
  동작에 맞춰 다시 쓰고, replaceSnapshot 위에 겹쳐 있던 고아 Javadoc 블록을 지운다.
- Helm chart는 mcp.bundles 구성에서 계속 유효하므로 삭제하지 않고, 내부망 운영
  대상이 아니라는 사실을 deploy/README.md와 values.yaml에 명시한다.

검증: 이 환경은 loopback이 막혀 gradlew check를 실행하지 못했다. CodeStyleContract가
보는 항목(줄바꿈, 탭, 행말 공백, 파일 끝 개행, 미사용 import, import 순서)은 변경된
Java 13개 파일에 대해 따로 재현해 확인했다. 컴파일과 테스트 실행은 미확인이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:10:34 +09:00
61 changed files with 3682 additions and 275 deletions

1
.gitattributes vendored
View File

@@ -20,5 +20,6 @@ gradlew text eol=lf
*.jpg binary
*.pdf binary
*.pptx binary
*.xlsx binary
*.p12 binary
*.jks binary

82
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,82 @@
name: CI
# main push와 PR에서 같은 검증을 돌린다. 이 워크플로는 배포하지 않는다.
on:
push:
branches:
- main
pull_request:
env:
# 빌드·렌더링 도구는 러너에 설치하지 않고 컨테이너로 가져온다.
# 러너에 JDK나 helm이 깔려 있는지에 파이프라인이 의존하지 않게 하기 위해서다.
# 러너가 host 모드(docker가 러너 호스트에서 직접 도는 구성)라는 전제다.
# 현재 .gitea/workflows/deploy.yaml이 호스트의 docker compose 스크립트를 부르므로 그 전제가 성립한다.
JDK_IMAGE: eclipse-temurin:21-jdk
HELM_IMAGE: alpine/helm:3.14.4
jobs:
verify:
runs-on: ubuntu
steps:
- uses: actions/checkout@v4
# 컨테이너를 러너 사용자로 돌린다. root로 돌면 build/ 산출물이 root 소유가 되어
# 다음 실행의 checkout이 그 디렉터리를 지우지 못한다.
# ideaFormatCheck는 IDEA_FORMATTER가 없으면 경고만 남기고 건너뛴다.
# 들여쓰기·줄바꿈은 CI에서 검증되지 않는다는 뜻이다(README).
- name: Gradle check
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/workspace -w /workspace \
-e GRADLE_USER_HOME=/workspace/.gradle/ci-home \
"$JDK_IMAGE" ./gradlew check --no-daemon
# HelmDeploymentContractTest는 values와 template의 정적 규칙만 본다.
# helper 오류와 조건 분기 실수는 실제로 렌더링해야 드러난다.
- name: Helm lint and template
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/workspace -w /workspace \
--entrypoint sh \
"$HELM_IMAGE" deploy/ci/render-manifests.sh
# GitOps 저장소가 생기기 전까지, 이 산출물이 "무엇이 배포되는가"를 확인할 수 있는 유일한 형태다.
- name: Upload rendered manifests
uses: actions/upload-artifact@v3
with:
name: rendered-manifests
path: build/rendered
image:
runs-on: ubuntu
needs: verify
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t "ax-hub-mcp-server:${{ github.sha }}" .
# registry가 아직 확정되지 않았으면 빌드까지만 하고 멈춘다.
# 없는 secret 때문에 파이프라인 전체가 실패로 보이는 것을 막는다.
# TODO: 사내 registry 확정 시 저장소 secret에 REGISTRY_HOST·REGISTRY_USER·REGISTRY_PASSWORD·IMAGE_REPOSITORY를 등록한다.
- name: Push image
env:
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
IMAGE_REPOSITORY: ${{ secrets.IMAGE_REPOSITORY }}
IMAGE_TAG: ${{ github.sha }}
run: |
if [ -z "$REGISTRY_HOST" ] || [ -z "$IMAGE_REPOSITORY" ]; then
echo "REGISTRY_HOST 또는 IMAGE_REPOSITORY secret이 없어 push를 건너뛴다."
echo "이미지는 러너 로컬에만 있다: ax-hub-mcp-server:$IMAGE_TAG"
exit 0
fi
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
docker tag "ax-hub-mcp-server:$IMAGE_TAG" "$IMAGE_REPOSITORY:$IMAGE_TAG"
docker push "$IMAGE_REPOSITORY:$IMAGE_TAG"
echo "배포에 쓸 tag: $IMAGE_TAG"

View File

@@ -0,0 +1,114 @@
name: Deploy to OpenShift
# GitOps 저장소와 ArgoCD Application이 아직 없어 CD를 push 방식으로 돌린다.
# 파이프라인이 클러스터에 직접 helm upgrade를 건다.
#
# 이 방식의 대가를 숨기지 않는다.
# - 클러스터의 실제 상태가 저장소와 자동으로 맞춰지지 않는다. 누가 oc edit으로 고치면 그대로 남는다.
# - 배포 이력이 Helm release history에만 남는다. git revert로 되돌릴 수 없다.
# - 파이프라인이 클러스터 자격증명을 들고 있어야 한다.
#
# 그래서 자동 트리거를 두지 않고 사람이 값을 확인하고 실행한다.
# GitOps 저장소가 준비되면 이 워크플로를 삭제하고, ArgoCD Application이 이 Chart를 당겨 가게 한다.
# 그때까지의 인수인계 형태는 CI가 올리는 rendered-manifests 아티팩트다.
on:
workflow_dispatch:
inputs:
environment:
description: 배포 환경 (dev | test | prod)
required: true
default: dev
mode:
description: 배포 모델 (portal | bundles)
required: true
default: portal
deploymentKey:
description: mode=bundles일 때 설치할 배포 key. portal이면 비워 둔다
required: false
default: ""
imageTag:
description: 배포할 이미지 tag. CI가 push한 commit sha를 그대로 넣는다
required: true
env:
HELM_IMAGE: alpine/helm:3.14.4
jobs:
deploy:
runs-on: ubuntu
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
MODE: ${{ github.event.inputs.mode }}
DEPLOYMENT_KEY: ${{ github.event.inputs.deploymentKey }}
IMAGE_TAG: ${{ github.event.inputs.imageTag }}
IMAGE_REPOSITORY: ${{ secrets.IMAGE_REPOSITORY }}
OCP_SERVER: ${{ secrets.OCP_SERVER }}
OCP_TOKEN: ${{ secrets.OCP_TOKEN }}
# 사내 CA가 서명한 API 인증서일 때 PEM 전체를 넣는다. 비어 있으면 러너의 신뢰 저장소를 쓴다.
OCP_CA_CERT: ${{ secrets.OCP_CA_CERT }}
OCP_NAMESPACE_DEV: ${{ secrets.OCP_NAMESPACE_DEV }}
OCP_NAMESPACE_TEST: ${{ secrets.OCP_NAMESPACE_TEST }}
OCP_NAMESPACE_PROD: ${{ secrets.OCP_NAMESPACE_PROD }}
run: |
set -eu
CA_FILE=.ocp-ca.crt
trap 'rm -f "$CA_FILE"' EXIT
case "$ENVIRONMENT" in
dev) NAMESPACE=$OCP_NAMESPACE_DEV ;;
test) NAMESPACE=$OCP_NAMESPACE_TEST ;;
prod) NAMESPACE=$OCP_NAMESPACE_PROD ;;
*) echo "environment는 dev|test|prod여야 한다: $ENVIRONMENT" >&2; exit 1 ;;
esac
for required in OCP_SERVER OCP_TOKEN IMAGE_REPOSITORY; do
eval "value=\${$required}"
if [ -z "$value" ]; then
echo "$required secret이 없다. 플랫폼 담당자에게 발급받아 저장소 secret에 등록한다." >&2
exit 1
fi
done
if [ -z "$NAMESPACE" ]; then
echo "$ENVIRONMENT namespace secret이 없다." >&2
exit 1
fi
# release 이름은 mode가 정한다. portal은 배포가 하나이고, bundles는 배포 key마다 하나다.
if [ "$MODE" = "portal" ]; then
RELEASE=axhub-mcp
EXTRA=""
else
if [ -z "$DEPLOYMENT_KEY" ]; then
echo "mode=bundles에는 deploymentKey가 필요하다." >&2
exit 1
fi
RELEASE="$DEPLOYMENT_KEY-mcp"
EXTRA="--set deploymentKey=$DEPLOYMENT_KEY"
fi
# 사내 CA를 신뢰시키는 정상 경로다. TLS 검증을 끄는 스위치는 두지 않는다.
if [ -n "$OCP_CA_CERT" ]; then
printf '%s\n' "$OCP_CA_CERT" > "$CA_FILE"
EXTRA="$EXTRA --kube-ca-file /workspace/$CA_FILE"
fi
# token은 helm 인자로 들어간다. 컨테이너는 명령마다 새로 뜨고 바로 사라지지만,
# 러너를 여러 팀이 공유하게 되면 kubeconfig 파일 방식으로 바꾼다.
# --atomic: 실패하면 직전 revision으로 되돌린다. 반쯤 배포된 상태로 두지 않는다.
docker run --rm \
-v "$PWD":/workspace -w /workspace \
"$HELM_IMAGE" upgrade --install "$RELEASE" deploy/helm/mcp-server \
-f "deploy/helm/mcp-server/values-$ENVIRONMENT.yaml" \
--namespace "$NAMESPACE" \
--set "mode=$MODE" \
$EXTRA \
--set "image.repository=$IMAGE_REPOSITORY" \
--set "image.tag=$IMAGE_TAG" \
--kube-apiserver "$OCP_SERVER" \
--kube-token "$OCP_TOKEN" \
--atomic --timeout 10m

4
.gitignore vendored
View File

@@ -38,4 +38,8 @@ secrets/
# Claude Code: 공유 설정(.claude/settings.json)은 커밋하고 개인 설정은 제외한다.
.claude/settings.local.json
# 에이전트 도구가 만드는 로컬 캐시·잠금 파일. 저장소 산출물이 아니다.
.ua/
skills-lock.json

105
README.md
View File

@@ -1,6 +1,7 @@
# AX HUB MCP Server
Agent Builder와 Tool Service 사이의 stateless MCP 실행 계층이다. Agent Builder가 `tools/call`에 명시한 단일 Tool을 JSON-RPC 2.0과 `inputSchema`로 검증하고, 서버가 관리하는 metadata에 따라 Tool Service를 호출한다.
Agent Builder와 Tool Service 사이의 stateless MCP 실행 계층이다. Agent Builder가 `tools/call`에 명시한 단일 Tool을 JSON-RPC 2.0과 `inputSchema`로 검증하고, 서버가 관리하는 metadata에 따라 Tool
Service를 호출한다.
이 서버는 Tool을 추천하거나 사용자 의도를 판단하지 않는다. 업무 규칙은 Tool Service가, Tool 선택과 사용자·Agent별 노출 정책은 Agent Builder가 소유한다.
@@ -24,16 +25,18 @@ SDK 적용 경계는 [MCP Java SDK 선택적 도입 설계](docs/mcp-java-sdk-ad
**빌드는 외부 저장소에서 코드 스타일 도구를 내려받지 않는다.** 폐쇄망에서 검사 하나 때문에 빌드 전체가 시작되지 못하는 상황을 만들지 않기 위해서다. 서식 검사는 저장소 안의 테스트가 소유한다.
Java 포맷은 `.idea/codeStyles/Project.xml`의 IntelliJ IDEA 코드 스타일로 고정한다. 이 파일은 저장소에 포함되어 있어 IDE에서 자동으로 적용된다. Java 소스의 줄바꿈은 운영체제와 무관하게 LF이며 `.gitattributes`가 commit 시점에 이를 강제한다.
Java 포맷은 `.idea/codeStyles/Project.xml`의 IntelliJ IDEA 코드 스타일로 고정한다. 이 파일은 저장소에 포함되어 있어 IDE에서 자동으로 적용된다. Java 소스의 줄바꿈은 운영체제와 무관하게 LF이며 `.gitattributes`가 commit
시점에 이를 강제한다.
두 가지가 보장하는 범위가 다르다.
| 무엇이 | 보장하는 것 | 조건 |
|---|---|---|
| `CodeStyleContractTest` | LF 줄바꿈, 탭 없음, 후행 공백 없음, 파일 끝 개행, 미사용 import 없음 | 항상 (`test`에 포함) |
| IntelliJ formatter | 4칸 들여쓰기, 줄바꿈 스타일, 단순 lambda·다중 표현식 분리 | `IDEA_FORMATTER` 설정 시에만 |
| 무엇이 | 보장하는 것 | 조건 |
|-------------------------|------------------------------------------------|-------------------------|
| `CodeStyleContractTest` | LF 줄바꿈, 탭 없음, 후행 공백 없음, 파일 끝 개행, 미사용 import 없음 | 항상 (`test`에 포함) |
| IntelliJ formatter | 4칸 들여쓰기, 줄바꿈 스타일, 단순 lambda·다중 표현식 분리 | `IDEA_FORMATTER` 설정 시에만 |
**`IDEA_FORMATTER`가 없으면 IntelliJ formatter 단계는 경고를 남기고 건너뛴다.** IntelliJ가 없는 CI나 폐쇄망 빌드에서 빌드가 깨지지 않게 하기 위한 것이며, 그 환경에서는 들여쓰기와 줄바꿈이 검증되지 않는다는 뜻이다. **도구 없이 판정할 수 있는 규칙은 그때도 계속 검사된다.**
**`IDEA_FORMATTER`가 없으면 IntelliJ formatter 단계는 경고를 남기고 건너뛴다.** IntelliJ가 없는 CI나 폐쇄망 빌드에서 빌드가 깨지지 않게 하기 위한 것이며, 그 환경에서는 들여쓰기와 줄바꿈이 검증되지 않는다는 뜻이다. **도구 없이 판정할 수
있는 규칙은 그때도 계속 검사된다.**
포맷터로 코드를 실제로 정리하려면 경로를 지정하고 `ideaFormat`을 실행한다. `CodeStyleContractTest`는 검사만 하고 고쳐 주지 않는다.
@@ -52,8 +55,9 @@ $env:MCP_LOCAL_TOOL_REGISTRY_FILE='file:C:/path/local-tools.json'
## 공개 계약
- 공개 endpoint: `POST https://{global.mcpHost}{deployments.<key>.publicPath}`
- 예: `https://mcp-dev.apps.example.internal/mcp/processing-critical`
- 공개 endpoint: `POST https://{global.mcpHost}/mcp/{routeKey}`
- 예: `https://mcp-dev.apps.example.internal/mcp/cus`
- route key는 URI에서만 결정된다. route 없는 `/mcp` 호출은 거부한다
- OpenShift Route는 공개 path로 MCP Service만 선택하고, 컨테이너가 같은 path를 직접 처리
- Method: `initialize`, `notifications/initialized`, `tools/list`, `tools/call`
- Response: 항상 단일 `application/json` JSON-RPC response
@@ -70,9 +74,9 @@ Agent Builder는 공개 URL마다 별도 MCP로 등록하고 initialize한다. U
| 환경 | Tool 원천 | Redis |
|---|---|---|
| `local` | local JSON fixture | 사용 안 함 |
| 운영(`ocp`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 |
| 운영(`ocp`) | Portal registry가 알려 준 route별 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 Portal registry fallback에 사용 |
요청 경로의 `tools/list``tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 모든 bundle에 사용 가능한 성공본이 있을 때만 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. 코드는 bundle N개 병합을 지원하지만 **운영 배포의 bundle은 항상 하나**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)).
요청 경로의 `tools/list``tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 그 route의 모든 bundle에 사용 가능한 성공본이 있을 때만 route의 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. **한 route에는 Tool Service가 여럿 붙을 수 있고, 병합 단위는 route**([ADR-0013](docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)).
Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정에서만 정한다. Agent Builder의 `arguments`와 Tool Service 매니페스트는 호출 대상을 바꿀 수 없다.
@@ -82,17 +86,17 @@ Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정
호출자가 보내는 헤더는 다섯 개이며 **모두 선택값**이다. 값은 그대로 Tool Service 요청 헤더로 bypass한다.
| 헤더 | 의미 | 없을 때 |
|---|---|---|
| `guid` | 요청 하나를 끝까지 따라가는 상관 값(UUID) | 서버가 생성 |
| `x-request-id` | 개별 HTTP 요청 ID | 서버가 생성 |
| `mcp-session-id` | initialize lifecycle 상관 값 | 전달하지 않음 |
| `employee-no` | 암호화된 사원번호 | 전달하지 않음 |
| `virtual-employee-no` | 암호화된 가상사원번호(상담사 등 비사원) | 전달하지 않음 |
| 헤더 | 의미 | 없을 때 |
|-----------------------|----------------------------|---------|
| `guid` | 요청 하나를 끝까지 따라가는 상관 값(UUID) | 서버가 생성 |
| `x-request-id` | 개별 HTTP 요청 ID | 서버가 생성 |
| `mcp-session-id` | initialize lifecycle 상관 값 | 전달하지 않음 |
| `employee-no` | 사원번호 | 전달하지 않음 |
| `virtual-employee-no` | 가상사원번호(상담사 등 비사원) | 전달하지 않음 |
`employee-no``virtual-employee-no`는 **MCP가 복호화하지 않는 불투명 값**이다. 형식이나 의미를 해석하지 않고, 개행이 섞여 downstream 헤더가 조작되는 것만 막은 뒤 그대로 전달한다.
`employee-no``virtual-employee-no`는 **불투명 값**이다. 형식이나 의미를 해석하지 않고, 개행이 섞여 downstream 헤더가 조작되는 것만 막은 뒤 그대로 전달한다.
MDC는 사용하지 않는다. 로그에는 `guid``x-request-id`만 남기며 **사원 식별자는 암호문이라도 기록하지 않는다.** request/response body와 credential도 남기지 않는다.
MDC는 사용하지 않는다. 로그에는 `guid``x-request-id`만 남기며 **사원 식별자는 기록하지 않는다.** request/response body와 credential도 남기지 않는다.
`Authorization``mcp.tool-client.forward-authorization` 설정이 켜진 경우에만 전달한다. MCP는 이 값을 해석하지 않는다.
@@ -100,20 +104,24 @@ MDC는 사용하지 않는다. 로그에는 `guid`와 `x-request-id`만 남기
**이 서버는 인증도 인가도 하지 않는다.** 요청자 신원을 검증하지 않고, Tool 실행 권한을 판단하지 않으며, 사원 식별자를 복호화하지 않는다. 결정과 근거는 [ADR-0006](docs/decisions/ADR-0006-no-authentication-in-mcp.md)이다.
| 책임 | 주체 |
|---|---|
| 책임 | 주체 |
|---------------------------|------------------------------|
| 외부 호출자를 Agent Builder로 제한 | OpenShift Route IP allowlist |
| MCP Pod 직접 접근 제한 | 플랫폼 NetworkPolicy |
| 사용자 인증과 Tool 실행 권한 | Agent Builder |
| 사원 식별자 복호화(KMS)와 업무 권한 | Tool Service |
| MCP Pod 직접 접근 제한 | 플랫폼 NetworkPolicy |
| 사용자 인증과 Tool 실행 권한 | Agent Builder |
| 업무 권한 | Tool Service |
⚠️ **Route IP allowlist와 NetworkPolicy는 선택 사항이 아니다.** 외부 요청은 Route가 Agent Builder의 고정 egress CIDR만 받고, backend 요청은 ingress controller 또는 허용된 Agent Builder namespace에서만 MCP Pod에 도달한다. 실제 CIDR을 넣지 않은 배포는 운영에 사용할 수 없다. `HelmDeploymentContractTest`가 두 경계가 Chart에서 빠지지 않도록 고정한다.
⚠️ **Route IP allowlist와 NetworkPolicy는 선택 사항이 아니다.** 외부 요청은 Route가 Agent Builder의 고정 egress CIDR만 받고, backend 요청은 ingress controller 또는 허용된 Agent Builder
namespace에서만 MCP Pod에 도달한다. 실제 CIDR을 넣지 않은 배포는 운영에 사용할 수 없다.
## 운영 설정
**아래 내용은 아직 미정으로 내부 CI/CD 정책에 따라 변경된다. (참고 용도로만 확인)**
운영 설정은 Helm Chart가 만드는 ConfigMap이 담당한다. `identity`와 bundle 설정을 환경변수로 나열하지 않는 이유는 항목이 흩어질수록 인덱스 실수가 조용한 오라우팅이 되기 때문이다.
`identity``{배포 이름}-{global.env}`로 template이 조립한다. 현재 Redis cache 구현이 이 값을 사용하지만, Redis key namespace와 공유 정책은 아직 확정되지 않았으므로 [extension-points.md](docs/extension-points.md#운영-적용-전-필수-보완)에서 합의한다.
`identity``{배포 이름}-{global.env}`로 template이 조립한다. 현재 Redis cache 구현이 이 값을 사용하지만, Redis key namespace와 공유 정책은 아직 확정되지
않았으므로 [extension-points.md](docs/extension-points.md#운영-적용-전-필수-보완)에서 합의한다.
- 업무 포트: `SERVER_PORT`(기본 8080)
- management 포트: `MANAGEMENT_SERVER_PORT`(운영 기본 9090)
@@ -123,42 +131,31 @@ MDC는 사용하지 않는다. 로그에는 `guid`와 `x-request-id`만 남기
readiness는 첫 Tool discovery 시도가 끝나고 usable in-memory snapshot이 있을 때만 UP이다. 원천 장애 중에도
기존 memory 또는 Redis last-good이 있으면 서비스를 유지하고, 아무 성공본도 없으면 트래픽을 받지 않는다.
**MCP 배포 하나는 Tool Service 하나만 본**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 bundle 목록을 늘리는 것이 아니라 배포를 하나 더 만드는 것이다. 외부에서는 같은 host의 고유 path로 각 배포를 노출하고 컨테이너가 그 path를 그대로 처리한다([ADR-0009](docs/decisions/ADR-0009-container-handles-public-mcp-path.md)). 배포는 업무 × 중요도 등급으로 나뉘며, 등급이 replica 수와 PodDisruptionBudget을 정한다.
**route↔Tool Service 매핑의 원천은 Portal이**([ADR-0013](docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). 배포 하나가 N개 route를 서비스하고, route key는 `/mcp/{routeKey}` URI에서만 결정된다. 매핑이 바뀌어도 재배포하지 않는다. 외부에서는 같은 host의 path로 route를 구분하고 컨테이너가 그 path를 그대로 처리한다([ADR-0009](docs/decisions/ADR-0009-container-handles-public-mcp-path.md)).
배포 정의는 [Helm Chart](deploy/helm/mcp-server/) 하나뿐이다. 배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 설치할 배포 하나는 `--set`으로 고른다.
배포 정의는 [Helm Chart](deploy/helm/mcp-server/) 하나뿐이다. Chart는 배포 모델 둘을 `mode`로 고른다. `portal`이 현재 애플리케이션이 실제로 도는 경로이고, `bundles`는 ADR-0013이 대체한 1:1 구성([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md))이다.
```bash
helm upgrade --install processing-critical-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n <namespace>
helm upgrade --install axhub-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml -n <namespace>
```
**MCP Server와 Tool Service는 같은 namespace에 배포한다.** 그래서 values에는 Tool Service의 이름만 적고 주소는 template이 조립한다. 환경마다 URL을 반복해 적지 않으므로 오타로 엉뚱한 곳을 호출할 수 없다.
배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 두 모드의 차이, 등급별 가용성, 확정 전 임시값은 [deploy/README.md](deploy/README.md)가 정본이다.
```yaml
deployments:
processing-critical:
name: processing-critical-mcp
service: processing-critical-tools # ← 이름만. 주소는 template이 만든다
namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다
tier: critical
publicPath: /mcp/processing-critical # ← 같은 환경 host 안에서 유일
```
평문 manifest가 필요하면 `deploy/ci/render-manifests.sh``helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다.
배포가 10개든 20개든 파일 수는 늘지 않는다. 자세한 사용법은 [deploy/README.md](deploy/README.md)에 있다.
평문 manifest가 필요하면 `helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다.
빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며 이 저장소가 정하지 않는다. 배포 시 알아야 할 앱 제약은 [deploy/README.md](deploy/README.md)에 정리했다.
빌드·이미지·배포 실행 방식은 원래 사내 표준 CI/CD가 담당한다. GitOps 저장소가 준비되기 전까지만 `.gitea/workflows/`가 임시로 그 역할을 하며, 그 방식이 무엇을 포기하는지와 넘길 때 할 일은 [deploy/README.md](deploy/README.md#gitops-저장소가-없는-동안의-우회)에 적었다. 배포 시 알아야 할 앱 제약도 같은 문서에 정리했다.
## 문서 R&R
| 문서 | 책임 |
|---|---|
| [README](README.md) | 프로젝트 진입점과 실행 방법 |
| [architecture.md](docs/architecture.md) | 현재 코드 구조, 요청 흐름, 내부 책임과 장애 동작 |
| [Agent Builder-MCP contracts](docs/contracts/agent-builder-mcp/README.md) | Agent Builder와의 HTTP/JSON-RPC wire 계약 |
| [Tool Service-MCP contracts](docs/contracts/tool-service-mcp/README.md) | 매니페스트와 Tool 실행 wire 계약 |
| [decisions](docs/decisions/README.md) | 결정 이유와 대안 이력 |
| [extension-points.md](docs/extension-points.md) | 아직 미합의인 항목과 운영 보완 작업 |
| [codex-workflow.md](docs/codex-workflow.md) | 저장소 작업 규칙과 공개 정책 |
| 문서 | 책임 |
|---------------------------------------------------------------------------|-----------------------------------------------------|
| [README](README.md) | 프로젝트 진입점과 실행 방법 |
| [architecture.md](docs/architecture.md) | 현재 코드 구조, 요청 흐름, 내부 책임과 장애 동작 |
| [Agent Builder-MCP contracts](docs/contracts/agent-builder-mcp/README.md) | Agent Builder와의 HTTP/JSON-RPC wire 계약 |
| [Tool Service-MCP contracts](docs/contracts/tool-service-mcp/README.md) | 매니페스트와 Tool 실행 wire 계약 |
| [Portal-MCP contracts](docs/contracts/portal-mcp/README.md) | Portal registry의 Tool Server endpoint 목록 조회 wire 계약 |
| [decisions](docs/decisions/README.md) | 결정 이유와 대안 이력 |
| [extension-points.md](docs/extension-points.md) | 아직 미합의인 항목과 운영 보완 작업 |
| [codex-workflow.md](docs/codex-workflow.md) | 저장소 작업 규칙과 공개 정책 |
Superseded/Rejected 문서는 이력일 뿐 현재 구현 근거가 아니다. 코드나 공개 계약을 변경할 때는 가까운 테스트와 해당 현재 계약을 함께 수정한다. 변경을 마치기 전에 실행할 검증 명령은 [AGENTS.md](AGENTS.md)의 완료 기준이 정본이다.

View File

@@ -72,9 +72,13 @@ tasks.named('check') {
group = 'io.shinhanlife.dap.biz.mcp'
version = '0.1.0'
// 표준가이드가 정한 배포판은 Eclipse Temurin(openjdk21u-jdk_..._hotspot_21.0.5)이다.
// 벤더를 적지 않으면 설치된 아무 JDK나 잡히므로, 가이드와 다른 배포판으로 조용히 빌드되는 것을 막는다.
// 폐쇄망에서는 toolchain 자동 다운로드가 동작하지 않으므로 빌드 머신에 Temurin이 미리 설치되어 있어야 한다.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
vendor = JvmVendorSpec.ADOPTIUM
}
}

View File

@@ -1,68 +1,80 @@
# 배포 정의
이 디렉터리는 **배포될 대상**을 정의한다. 빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며
이 저장소가 정하지 않는다.
이 디렉터리는 **배포될 대상**과, 그것을 클러스터에 올리는 **임시 경로**를 정의한다.
빌드·배포 실행 방식의 정본은 원래 사내 표준 CI/CD이며 이 저장소가 정하지 않는다.
지금 여기 파이프라인이 있는 이유는 [아래](#gitops-저장소가-없는-동안의-우회)에 적었다.
> **내부망 운영은 이 Chart를 사용하지 않는다**([ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) 결정 7).
> 여기 정의된 토폴로지는 배포 하나가 Tool Service 하나를 보는 `mcp.bundles` 구성(ADR-0007/0009)을 전제한다.
> 내부망 운영은 endpoint 목록과 route 매핑의 원천을 Portal로 옮겼고, 배포 하나가 N개 route를 서비스한다.
>
> Chart를 지우지 않는 이유는 `mcp.bundles` 구성이 코드에서 사라지지 않았고 local 검증과 1:1 배포가
> 필요한 환경에서 그대로 유효하기 때문이다. **다만 아래 `deployments` 목록의 업무 이름은 예시이며
> 실제 배포 대상이 아니다.** Portal 구성으로 갈 환경에 이 Chart를 적용하면 route가 하나로 고정된다.
## Helm Chart
[helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다. values는 두 축으로 나뉜다.
[helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다.
### 배포 모델이 두 가지다
| mode | 무엇이 route↔Tool Service 매핑을 소유하는가 | 근거 |
|---|---|---|
| `portal` (기본값) | **Portal.** 배포 하나가 N개 route를 서비스하고 route key는 `/mcp/{routeKey}` URI에서만 온다 | [ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) |
| `bundles` | **배포 정의.** 배포 하나가 Tool Service 하나만 보고 매핑을 배포 시점에 못박는다 | [ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md) |
**현재 애플리케이션이 실제로 도는 경로는 `portal`이다.** `bundles`는 ADR-0013이 대체했지만 코드 경로가
남아 있어 1:1 검증과 격리 배포에 쓸 수 있다. 어느 쪽을 운영에 쓸지는 아직 확정되지 않았고
[extension-points.md](../docs/extension-points.md)에서 관리한다.
`mode`를 바꾸면 ConfigMap의 Tool 원천이 통째로 바뀐다. 값 하나로 배포 성격이 달라지므로
설치 명령에 항상 명시한다.
### values는 두 축으로 나뉜다
| 파일 | 소유하는 것 |
|---|---|
| `values.yaml` | **배포 토폴로지.** 어떤 MCP가 어떤 Tool Service를 보는가, 공개 path, 가용성 등급 |
| `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 이미지, 공개 host·허용 CIDR, 등급별 replica·PDB, 리소스 |
| `values.yaml` | **배포 토폴로지.** mode, portal 배포 정의, bundles 배포 목록, 등급 기준 |
| `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 공개 host·허용 CIDR, Portal registry 주소, 등급별 replica·PDB, 리소스 |
설치할 때 두 번째 축을 `-f`로, 첫 번째 축에서 고를 배포 하나를 `--set deploymentKey=`로 지정한다.
환경 파일은 토폴로지를 갖지 않는다. `HelmDeploymentContractTest`가 그 경계를 고정한다.
```bash
helm upgrade --install processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n <namespace>
# portal 모드. 배포가 하나이므로 deploymentKey가 없다.
helm upgrade --install axhub-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml -n <namespace>
# bundles 모드. 설치할 배포 하나를 반드시 고른다.
helm upgrade --install processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml \
--set mode=bundles --set deploymentKey=processing-critical -n <namespace>
```
`deploymentKey`에는 기본값이 없다. 지정을 빠뜨리면 렌더링 단계에서 멈춘다.
엉뚱한 배포가 조용히 설치되는 것보다 낫다.
`deploymentKey`에는 기본값이 없다. `bundles`에서 지정을 빠뜨리면 렌더링 단계에서 멈춘다.
엉뚱한 배포가 조용히 설치되는 것보다 낫다. 반대로 `portal`에서 `deploymentKey`를 주면 역시 멈춘다.
route를 배포 정의에 적기 시작하면 Portal을 원천으로 둔 이유가 사라지기 때문이다.
### 공 host와 배포별 path
### 공 host와 path
[ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md)에 따라 한 환경은 하나의 공개 host를 사용하고,
각 Helm release는 고유 path의 OpenShift Route를 만든다. Route는 Service만 선택하고 공개 path를 그대로
전달하며, 컨테이너가 같은 path를 직접 처리한다.
한 환경은 하나의 공개 host를 사용한다([ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md)).
Route는 Service만 선택하고 공개 path를 그대로 전달하며, 컨테이너가 같은 path를 직접 처리한다.
`portal` 모드에서 Route path는 `/mcp` 하나다. OpenShift Route의 path는 prefix 매칭이므로
`/mcp/{routeKey}` 전체가 이 Route로 들어오고, route 구분은 컨테이너가 한다.
```text
https://mcp-dev.apps.example.internal/mcp/cus -> axhub-mcp:8080/mcp/cus
https://mcp-dev.apps.example.internal/mcp/sal -> axhub-mcp:8080/mcp/sal
```
`bundles` 모드에서는 Route가 배포마다 하나씩 생기고 path가 배포별로 다르다.
```text
https://mcp-dev.apps.example.internal/mcp/processing-critical -> processing-critical-mcp:8080/mcp/processing-critical
https://mcp-dev.apps.example.internal/mcp/information-standard -> information-standard-mcp:8080/mcp/information-standard
```
공개 URL은 각각 독립된 MCP다. Agent Builder는 URL별로 등록하고 initialize하며, 한 Route나 MCP Pod의
장애가 다른 path의 Deployment로 전파되지 않는다.
### MCP 하나는 Tool Service 하나만 본다
[ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md)의 결정이다. 대상을 늘리는 방법은
bundle 목록을 늘리는 것이 아니라 **배포를 하나 더 만드는 것**이다.
```yaml
deployments:
processing-critical:
name: processing-critical-mcp
service: processing-critical-tools # ← 이름만. 주소는 template이 만든다
namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다
tier: critical
publicPath: /mcp/processing-critical # ← 환경 host 안에서 유일
```
배포가 10개든 20개든 **파일 수는 늘지 않는다.** 전체 매핑을 한 화면에서 검토할 수 있고,
`--set`으로 고르는 값 하나만 배포마다 달라진다.
MCP Server와 Tool Service는 같은 namespace에 배포하므로 values에는 서비스 이름만 적고
주소는 template이 조립한다. 환경마다 URL을 반복하지 않으므로 오타로 다른 대상을 호출할 수 없다.
`identity``{배포 이름}-{global.env}`로 template이 조립한다. 현재 Redis cache 구현이 이 값을
사용하지만, key namespace와 공유 정책은 아직 확정되지 않았다.
두 경우 모두 공개 URL은 각각 독립된 MCP다. Agent Builder는 URL별로 등록하고 initialize한다.
### 가용성 등급
배포를 업무 × 중요도로 나누는 목적은 **중요 등급에만 비용을 쓰기 위해서**다.
```yaml
tiers:
critical: { replicas: 3, podDisruptionBudget: true, spreadAcrossNodes: true }
@@ -71,27 +83,74 @@ tiers:
test와 prod의 `critical`은 **replica 2 이상, PodDisruptionBudget, 노드 분산 설정이 필수**다. replica가
1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내릴 수 있다.
`HelmDeploymentContractTest`는 values와 template의 정적 규칙을 검사한다. dev는 배포마다 Pod 1개로
운영하므로 이 검사 대상이 아니다.
dev는 배포마다 Pod 1개로 운영하므로 이 검사 대상이 아니다.
정적 테스트는 Helm 렌더러를 실행하지 않는다. 실제 배포 파이프라인은 사용하는 환경과 등급별로
`helm lint``helm template`을 실행해 병합된 values와 생성 YAML을 확인해야 한다.
**`portal` 모드에서 등급별 물리 분리는 성립하지 않는다.** 배포가 하나이므로 전 route가 같은
프로세스·같은 replica set을 공유한다([ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) 전제 2).
`tiers`는 그 하나의 배포에 어떤 가용성 기준을 적용할지만 정한다.
### 렌더링 검증
`HelmDeploymentContractTest`는 values와 template의 **정적 규칙**만 본다. helper 오류, 조건 분기 실수,
들여쓰기는 실제로 렌더링해야 드러난다. 두 검사는 서로를 대신하지 못한다.
```bash
helm lint helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-critical
helm template processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-critical
helm template processing-standard-mcp helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-standard
deploy/ci/render-manifests.sh # 환경 × 모드 전 조합 lint + template
```
**나누는 것만으로 가용성이 생기지는 않는다.** 같은 노드 배치, namespace 쿼터, 공통 Redis·클러스터
장애는 분할로 막히지 않는다. 남은 작업은 [extension-points.md](../docs/extension-points.md)의
"운영 적용 전 필수 보완"에서 관리한다.
CI가 매 push에서 같은 스크립트를 돌리고 결과를 `rendered-manifests` 아티팩트로 올린다.
### dev에서 MCP에 연결되지 않을 때
## GitOps 저장소가 없는 동안의 우회
Chart를 어디에 둘지, 배포를 무엇이 실행할지는 아직 확정되지 않았다. GitOps 저장소도 ArgoCD
Application도 없다. 그동안 파이프라인을 멈춰 두지 않기 위해 아래 형태로 돌린다.
| 파일 | 트리거 | 하는 일 |
|---|---|---|
| `.gitea/workflows/ci.yaml` | main push, PR | `gradlew check`, Chart lint·template, 이미지 빌드·push |
| `.gitea/workflows/deploy-openshift.yaml` | 수동 실행 | 고른 환경·모드로 `helm upgrade --install` |
| `.gitea/workflows/deploy.yaml` | main push | 기존 VM docker compose 배포 |
### 이 방식이 무엇을 포기하는가
숨기지 않고 적는다. GitOps로 넘어가는 판단의 근거가 되기 때문이다.
- **클러스터 상태가 저장소와 자동으로 맞춰지지 않는다.** 누가 `oc edit`으로 고치면 그대로 남는다.
- **배포 이력이 Helm release history에만 남는다.** `git revert`로 되돌릴 수 없고 `helm rollback`을 써야 한다.
- **파이프라인이 클러스터 자격증명을 들고 있어야 한다.** 러너를 신뢰 경계 안에 두어야 한다.
- **어떤 이미지가 어느 환경에 떠 있는지 저장소만 봐서는 모른다.** 수동 실행 이력을 봐야 한다.
이 때문에 OpenShift 배포에는 자동 트리거를 두지 않았다. 사람이 환경·모드·이미지 tag를 확인하고 실행한다.
### GitOps 저장소가 생기면
1. `deploy-openshift.yaml`을 삭제한다. 클러스터 자격증명 secret도 회수한다.
2. ArgoCD Application이 이 Chart를 참조하게 하거나, Chart 자체를 배포 저장소로 옮긴다.
3. CI의 `rendered-manifests` 아티팩트가 **인수인계 형태**다. 그 시점에 무엇이 배포되고 있었는지가
거기 그대로 있으므로, 옮긴 뒤 diff로 대조한다.
4. `deploy.yaml`의 VM compose 배포를 계속 쓸지 결정한다. 스크립트가 저장소 밖(러너의
`/home/ubuntu/apps/prd-dap-gateway/deploy.sh`)에 있어 이 저장소가 내용을 모른다.
### 아직 필요한 secret
확정 전까지 CI는 이미지 빌드까지만 하고 push를 건너뛴다. 없는 secret 때문에 파이프라인 전체가
실패로 보이지 않게 하기 위해서다.
| secret | 쓰는 곳 | 없으면 |
|---|---|---|
| `REGISTRY_HOST`·`REGISTRY_USER`·`REGISTRY_PASSWORD`·`IMAGE_REPOSITORY` | CI 이미지 push | push 건너뜀 |
| `OCP_SERVER`·`OCP_TOKEN` | OpenShift 배포 | 배포 실패 |
| `OCP_CA_CERT` | API 인증서를 사내 CA가 서명했을 때 | 러너의 신뢰 저장소를 쓴다. 사내 CA면 TLS 검증 실패 |
| `OCP_NAMESPACE_DEV`·`OCP_NAMESPACE_TEST`·`OCP_NAMESPACE_PROD` | OpenShift 배포 | 배포 실패 |
## dev에서 MCP에 연결되지 않을 때
**먼저 Tool Service가 떠 있는지 확인한다.** readiness가 usable snapshot을 요구하므로, Tool Service가
없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다. dev는 배포마다 Pod 1개라
그 순간 그 MCP로는 아예 연결되지 않는다. "MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다.
없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다.
"MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다.
`portal` 모드에서는 Portal registry 조회부터 확인한다. registry를 못 읽으면 route 자체가 등록되지 않아
`/mcp/{routeKey}` 호출이 route key 검증에서 거부된다.
```bash
kubectl get pod -l app=<배포 이름> # 0/1 Ready이면 이 경우다
@@ -99,14 +158,15 @@ kubectl describe pod <pod> # Readiness probe 실패 사유
kubectl port-forward <pod> 9090:9090 # /actuator/toolBundles로 bundle 상태 확인
```
Tool Service가 뜨면 다음 refresh 주기(기본 30초) 안에 스스로 Ready가 된다. 재기동할 필요가 없다.
Tool Service가 뜨면 다음 refresh 주기 안에 스스로 Ready가 된다. 재기동할 필요가 없다.
`/actuator/toolBundles`는 management 포트라 NetworkPolicy가 관제 namespace로 제한하므로,
개발자는 위처럼 `port-forward`로 본다.
## 확정 전 임시값
`values.yaml`의 Tool Service 이름·이미지 경로와 `values-{env}.yaml`의 namespace·공개 host·Route 허용 CIDR은 자리표시자다.
각 파일의 `TODO` 주석을 참고해 확정 시 교체하고, 존재하지 않는 배포는 `deployments`에서 삭제한다.
`values.yaml` 이미지 경로와 Tool Service 이름, `values-{env}.yaml`의 namespace·공개 host·Route 허용
CIDR·Portal registry 주소는 자리표시자다. 각 파일의 `TODO` 주석을 참고해 확정 시 교체하고,
존재하지 않는 배포는 `deployments`에서 삭제한다.
## 배포 시 알아야 할 앱 제약
@@ -119,14 +179,14 @@ Tool Service가 뜨면 다음 refresh 주기(기본 30초) 안에 스스로 Read
| `terminationGracePeriodSeconds`는 Spring drain보다 길어야 한다 | [architecture.md의 요청 시간 예산](../docs/architecture.md#요청-시간-예산) |
| **NetworkPolicy는 필수다. 비활성화 스위치를 두지 않았다** | [ADR-0006](../docs/decisions/ADR-0006-no-authentication-in-mcp.md) |
| 공개 path는 Route와 컨테이너 endpoint가 동일하게 사용한다 | [ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md) |
| route↔Tool Service 매핑의 원천은 Portal이다 | [ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) |
| Portal 응답 모양과 실패 처리 | [Portal-MCP 계약 v0.1](../docs/contracts/portal-mcp/protocol-v0.1-registry.md) |
| Redis key·TTL·공유 정책은 확정 전이다 | [extension-points.md](../docs/extension-points.md#운영-적용-전-필수-보완) |
| bundle은 정확히 하나다 | [ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md) |
Route IP allowlist와 NetworkPolicy는 특히 중요하다. 이 서버는 인증·인가를 하지 않으므로 `/mcp`
도달할 수 있다는 것이 곧 인가다. Route는 Agent Builder 고정 egress CIDR만 받고, NetworkPolicy는 Route
backend인 ingress controller와 명시한 Agent Builder namespace만 업무 포트에 허용한다.
## 미확정 항목
배포 정의를 이 저장소가 어디까지 소유하는지, namespace·registry 명명 규칙은 아직 확정되지 않았다.
[docs/extension-points.md](../docs/extension-points.md)에서 관리한다.
`portal` 모드는 여기에 하나를 더한다. **MCP는 Portal registry가 준 주소를 그대로 호출한다.** Portal이
신뢰 경계 안에 있다는 전제가 깨지면 MCP의 outbound 대상이 통째로 바뀐다([ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) 전제 4).
egress 제한은 아직 없으며 [extension-points.md](../docs/extension-points.md#운영-적용-전-필수-보완)에서 관리한다.

View File

@@ -0,0 +1,59 @@
#!/bin/sh
# Chart를 실제로 렌더링해 배포될 YAML을 만든다.
#
# 존재 이유가 둘이다.
# 1. 검증. HelmDeploymentContractTest는 values와 template의 정적 규칙만 본다.
# helper 오류, 잘못된 들여쓰기, 조건 분기 실수는 렌더링해야 드러난다.
# 2. 인수인계. GitOps 저장소가 아직 없으므로 여기서 나온 YAML이 "지금 무엇이 배포되는가"의
# 유일한 확인 가능한 형태다. 저장소가 생기면 이 산출물을 그대로 옮기면 된다.
#
# helm 바이너리가 PATH에 있어야 한다. CI는 helm 컨테이너 안에서 이 스크립트를 실행한다.
set -eu
CHART=deploy/helm/mcp-server
OUT=${OUT_DIR:-build/rendered}
# values.yaml의 deployments에서 배포 key 목록을 뽑는다.
# 목록의 정본은 values.yaml 하나이며 여기에 복사해 두지 않는다.
deployment_keys() {
sed -n '/^deployments:/,/^[a-z]/p' "$CHART/values.yaml" |
sed -n 's/^ \([a-z0-9-]*\):$/\1/p'
}
rm -rf "$OUT"
mkdir -p "$OUT"
keys=$(deployment_keys)
if [ -z "$keys" ]; then
echo "values.yaml의 deployments에서 배포 key를 찾지 못했다. 형식이 바뀌었는지 확인한다." >&2
exit 1
fi
for env in dev test prod; do
values="$CHART/values-$env.yaml"
namespace="ax-hub-$env"
# portal 모드. 배포 하나가 전 route를 서비스한다(ADR-0013).
echo "== lint $env / portal"
helm lint "$CHART" -f "$values"
echo "== render $env / portal"
helm template axhub-mcp "$CHART" -f "$values" \
--namespace "$namespace" \
>"$OUT/$env-portal.yaml"
# bundles 모드. 배포마다 Tool Service 하나(ADR-0007).
# 토폴로지 전체를 돌려야 등급별 replica·PDB·노드 분산 분기가 모두 렌더링된다.
for key in $keys; do
echo "== lint $env / bundles / $key"
helm lint "$CHART" -f "$values" --set mode=bundles --set "deploymentKey=$key"
echo "== render $env / bundles / $key"
helm template "$key-mcp" "$CHART" -f "$values" \
--set mode=bundles --set "deploymentKey=$key" \
--namespace "$namespace" \
>"$OUT/$env-bundles-$key.yaml"
done
done
echo
echo "렌더링 결과: $OUT"
ls -1 "$OUT"

View File

@@ -0,0 +1,277 @@
# AX HUB MCP 서버 개발계 수동 배포 샘플
#
# 주의:
# - 이 파일은 Helm template이 아니라, AA와 값을 협의한 뒤 수동으로 적용할 Raw OpenShift YAML 샘플이다.
# - "{{대문자_이름}}"은 확정되지 않은 값이다. 모든 자리표시자를 실제 값으로 교체한 뒤 적용한다.
# - 비밀번호와 API Key를 담는 Secret 및 그 참조는 현재 사용하지 않으므로 포함하지 않았다.
# - Harbor 인증이 필요하면 AA가 별도로 ServiceAccount에 image pull secret을 연결해야 한다.
# - 이 파일은 임시 수동 배포용 예제이며, 배포 정의의 정본은 deploy/helm/mcp-server Chart다.
#
# AA와 협의할 값:
# - {{DEV_NAMESPACE}}: MCP 서버를 배포할 개발계 namespace
# - {{HARBOR_IMAGE_REPOSITORY}}: Harbor project를 포함한 이미지 경로. 예: harbor.example/axhub/axhub-mcp
# - {{IMAGE_TAG}}: AA가 Podman으로 만들어 Push한 이미지 tag
# - {{DEV_MCP_HOST}}: 개발계 OpenShift Route host
# - {{AGENT_BUILDER_EGRESS_CIDR}}: Route 접근을 허용할 Agent Builder의 고정 egress CIDR
# - {{AGENT_BUILDER_NAMESPACE}}: Agent Builder Pod이 있는 namespace
# - {{DEV_PORTAL_REGISTRY_URL}}: 개발계 Portal registry API 주소
# - {{REDIS_SERVICE_HOST}}: 개발계 Redis Service host 또는 FQDN
# - {{CONFIG_VERSION}}: ConfigMap을 바꿀 때마다 증가시키는 값. 예: 1, 2, 3
#
# 적용 전 자리표시자 확인 예시(PowerShell):
# Get-Content .\deploy\examples\axhub-mcp-dev-manual.template.yaml |
# Where-Object { $_ -notmatch '^\s*#' } |
# Select-String -Pattern '\{\{[A-Z0-9_]+\}\}'
#
# 적용 예시:
# oc apply -f .\deploy\examples\axhub-mcp-dev-manual.template.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: axhub-mcp-config
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
data:
# SPRING_PROFILES_ACTIVE=dev이므로 파일명도 application-dev.yml이어야 한다.
application-dev.yml: |
management:
server:
port: 9090
health:
redis:
enabled: false
mcp:
# 환경별 Redis key가 서로 겹치지 않도록 개발계 identity를 고정한다.
identity: axhub-mcp-dev
# Route가 경로를 변경하지 않고 그대로 전달하므로 Route path와 같아야 한다.
endpoint-path: "/mcp"
registry:
refresh-interval-seconds: 30
refresh-jitter-seconds: 5
discovery:
enabled: true
redis:
enabled: true
# Portal 조회 실패 시 사용하는 Redis fallback key다. Portal과 같은 key인지 확인한다.
portal-registry-key: "axhub:mcp:portal-registry"
portal:
enabled: true
registry-url: "{{DEV_PORTAL_REGISTRY_URL}}"
refresh-interval-seconds: 60
# Portal이 route와 Tool Server 주소를 제공하므로 정적 bundle은 두지 않는다.
bundles: []
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: axhub-mcp
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
spec:
# 개발계 임시 테스트이므로 Pod 한 개로 구성한다.
replicas: 1
selector:
matchLabels:
app: axhub-mcp
template:
metadata:
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
annotations:
# Raw YAML은 Helm checksum을 자동 생성하지 못한다. ConfigMap 변경 시 이 값을 올리면 Pod이 재기동된다.
ax-hub/config-version: "{{CONFIG_VERSION}}"
spec:
terminationGracePeriodSeconds: 45
containers:
- name: mcp-server
image: "{{HARBOR_IMAGE_REPOSITORY}}:{{IMAGE_TAG}}"
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: management
containerPort: 9090
protocol: TCP
env:
- name: SPRING_PROFILES_ACTIVE
value: dev
# ConfigMap의 application-dev.yml을 JAR 내부 설정보다 우선 적용한다.
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
value: file:/opt/app/config/
- name: REDIS_HOST
value: "{{REDIS_SERVICE_HOST}}"
- name: REDIS_PORT
value: "16379"
- name: MANAGEMENT_SERVER_PORT
value: "9090"
volumeMounts:
- name: config
mountPath: /opt/app/config
readOnly: true
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: management
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: management
initialDelaySeconds: 20
periodSeconds: 20
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
volumes:
- name: config
configMap:
name: axhub-mcp-config
---
apiVersion: v1
kind: Service
metadata:
name: axhub-mcp
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
spec:
type: ClusterIP
selector:
app: axhub-mcp
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: axhub-mcp
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
annotations:
haproxy.router.openshift.io/timeout: 300s
# 이 서버는 자체 인증을 하지 않으므로 반드시 실제 Agent Builder 고정 egress CIDR로 제한한다.
haproxy.router.openshift.io/ip_allowlist: "{{AGENT_BUILDER_EGRESS_CIDR}}"
spec:
host: "{{DEV_MCP_HOST}}"
# /mcp/{routeKey} 요청도 prefix match로 이 Route에 들어온다. rewrite는 사용하지 않는다.
path: /mcp
to:
kind: Service
name: axhub-mcp
weight: 100
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
wildcardPolicy: None
---
# MCP 서버는 자체 인증·인가를 하지 않으므로 NetworkPolicy를 제거하면 안 된다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: axhub-mcp-ingress
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
spec:
podSelector:
matchLabels:
app: axhub-mcp
policyTypes:
- Ingress
ingress:
# OpenShift Route를 통과한 요청을 8080 포트로 허용한다.
- from:
- namespaceSelector:
matchLabels:
policy-group.network.openshift.io/ingress: ""
ports:
- protocol: TCP
port: 8080
# 같은 클러스터 안에서 Agent Builder가 직접 호출하는 경우만 8080 포트로 허용한다.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: "{{AGENT_BUILDER_NAMESPACE}}"
ports:
- protocol: TCP
port: 8080
# Actuator management 포트는 OpenShift 관제 namespace에서만 접근하도록 제한한다.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: openshift-monitoring
ports:
- protocol: TCP
port: 9090

Binary file not shown.

View File

@@ -4,6 +4,7 @@ description: AX HUB MCP Server - Agent Builder와 Tool Service 사이의 statele
type: application
# Chart 자체의 버전. 애플리케이션 버전과 따로 올린다.
version: 0.1.0
# 0.2.0에서 배포 모델이 두 가지(portal·bundles)가 되어 values 구조가 바뀌었다.
version: 0.2.0
# 기본 이미지 tag. 배포 시 values의 image.tag가 덮어쓴다.
appVersion: "0.1.0"

View File

@@ -2,8 +2,27 @@
, .
"nil pointer" .
template .
required의 .
host와 CIDR이 . .
mode에 . portal route Portal이 (ADR-0013)
deploymentKey가 registryUrl이 . bundles .
*/}}
{{- define "mcp-server.validate" -}}
{{- if not (has .Values.mode (list "portal" "bundles")) -}}
{{- fail (printf "mode는 portal 또는 bundles여야 한다: %v" .Values.mode) -}}
{{- end -}}
{{- if eq .Values.mode "portal" -}}
{{- $_ := required "mode=portal이면 portal.deployment.name을 지정해야 한다." .Values.portal.deployment.name -}}
{{- $_ = required "mode=portal이면 portal.registryUrl에 Portal registry 주소를 지정해야 한다. 환경별 values-{env}.yaml이 소유한다." .Values.portal.registryUrl -}}
{{- if not (index .Values.tiers .Values.portal.deployment.tier) -}}
{{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다." .Values.portal.deployment.tier) -}}
{{- end -}}
{{- if .Values.deploymentKey -}}
{{- fail "mode=portal에서는 deploymentKey를 쓰지 않는다. route는 /mcp/{routeKey} URI에서만 결정된다(ADR-0013)." -}}
{{- end -}}
{{- else -}}
{{- $key := required "deploymentKey를 지정해야 한다. 예: --set deploymentKey=processing-critical" .Values.deploymentKey -}}
{{- $deployment := index .Values.deployments $key -}}
{{- if not $deployment -}}
@@ -12,20 +31,42 @@
{{- if not (index .Values.tiers $deployment.tier) -}}
{{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다. deployments의 tier와 tiers의 key가 어긋났다." $deployment.tier) -}}
{{- end -}}
{{- required "global.mcpHost에 환경별 공개 MCP host를 지정해야 한다." .Values.global.mcpHost -}}
{{- required "route.sourceAllowlist에 Agent Builder의 고정 egress CIDR을 지정해야 한다." .Values.route.sourceAllowlist -}}
{{- $publicPath := required (printf "deployments.%s.publicPath를 지정해야 한다." $key) $deployment.publicPath -}}
{{- if not (regexMatch "^/mcp/[a-z0-9-]+$" $publicPath) -}}
{{- fail (printf "deployments.%s.publicPath는 /mcp/<영문 소문자·숫자·하이픈> 형식이어야 한다: %s" $key $publicPath) -}}
{{- end -}}
{{- $_ := required "global.mcpHost에 환경별 공개 MCP host를 지정해야 한다." .Values.global.mcpHost -}}
{{- $_ = required "route.sourceAllowlist에 Agent Builder의 고정 egress CIDR을 지정해야 한다." .Values.route.sourceAllowlist -}}
{{- $publicPath := required "선택된 배포의 publicPath를 지정해야 한다." (include "mcp-server.selectedDeployment" . | fromYaml).publicPath -}}
{{- if not (regexMatch "^/mcp(/[a-z0-9-]+)?$" $publicPath) -}}
{{- fail (printf "publicPath는 /mcp 또는 /mcp/<영문 소문자·숫자·하이픈> 형식이어야 한다: %s" $publicPath) -}}
{{- end -}}
{{- end -}}
{{/*
. namespace에 MCP .
dict로 . mode가 ,
template은 (name·tier·publicPath) .
`include ... | fromYaml` . Helm helper는 .
. validate가 helper를 .
*/}}
{{- define "mcp-server.selectedDeployment" -}}
{{- if eq .Values.mode "portal" -}}
{{ toYaml .Values.portal.deployment }}
{{- else -}}
{{ toYaml (index .Values.deployments .Values.deploymentKey) }}
{{- end -}}
{{- end -}}
{{/*
. namespace에 MCP .
*/}}
{{- define "mcp-server.name" -}}
{{- include "mcp-server.validate" . -}}
{{- (index .Values.deployments .Values.deploymentKey).name -}}
{{- (include "mcp-server.selectedDeployment" . | fromYaml).name -}}
{{- end -}}
{{/*
.
*/}}
{{- define "mcp-server.tier" -}}
{{- (include "mcp-server.selectedDeployment" . | fromYaml).tier -}}
{{- end -}}
{{/*
@@ -38,12 +79,12 @@ Redis key namespace가 되는 식별자.
{{- end -}}
{{/*
MCP가 Tool Service의 host:port.
MCP가 Tool Service의 host:port. bundles .
MCP와 Tool Service는 namespace이므로 FQDN이 .
( v0.2 §1). .
portal Portal registry가 helper를 .
*/}}
{{- define "mcp-server.toolServiceHost" -}}
{{- include "mcp-server.validate" . -}}
{{- $deployment := index .Values.deployments .Values.deploymentKey -}}
{{- printf "%s.%s.svc.cluster.local:%v" $deployment.service .Release.Namespace .Values.toolService.port -}}
{{- end -}}
@@ -54,7 +95,8 @@ app.kubernetes.io/name: {{ include "mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/tier: {{ (index .Values.deployments .Values.deploymentKey).tier }}
ax-hub/mode: {{ .Values.mode }}
ax-hub/tier: {{ include "mcp-server.tier" . }}
{{- end -}}
{{- define "mcp-server.selectorLabels" -}}

View File

@@ -1,9 +1,8 @@
# 배포별로 달라지는 설정만 담는다.
# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고,
# 이 파일이 같은 이름으로 덮어써 identity와 bundle만 배포 시점에 결정한다.
# 이 파일이 같은 이름으로 덮어써 identity와 Tool 원천만 배포 시점에 결정한다.
{{- include "mcp-server.validate" . }}
{{- $deployment := index .Values.deployments .Values.deploymentKey }}
{{- $toolServiceHost := include "mcp-server.toolServiceHost" . }}
{{- $deployment := include "mcp-server.selectedDeployment" . | fromYaml }}
apiVersion: v1
kind: ConfigMap
metadata:
@@ -19,19 +18,41 @@ data:
endpoint-path: {{ $deployment.publicPath | quote }}
registry:
refreshIntervalSeconds: {{ .Values.mcp.refreshIntervalSeconds }}
refreshJitterSeconds: {{ .Values.mcp.refreshJitterSeconds }}
refresh-interval-seconds: {{ .Values.mcp.refreshIntervalSeconds }}
refresh-jitter-seconds: {{ .Values.mcp.refreshJitterSeconds }}
discovery:
# 운영 profile은 Tool Service 매니페스트만 원천으로 쓴다.
enabled: true
redis:
# Portal 조회가 실패한 cold start에서만 읽는 fallback key다.
# 포털이 쓰는 key와 반드시 같아야 한다.
portal-registry-key: {{ .Values.portal.registryRedisKey | quote }}
{{- if eq .Values.mode "portal" }}
# route↔Tool Service 매핑의 원천은 Portal이다(ADR-0013).
# 배포 하나가 N개 route를 서비스하고, route key는 /mcp/{routeKey} URI에서만 결정된다.
# 매핑이 바뀌어도 이 ConfigMap을 고치지 않는다. 그것이 Portal을 원천으로 둔 이유다.
portal:
enabled: true
registry-url: {{ .Values.portal.registryUrl | quote }}
refresh-interval-seconds: {{ .Values.portal.refreshIntervalSeconds }}
# Portal이 주소를 소유하므로 정적 bundle을 선언하지 않는다.
bundles: []
{{- else }}
portal:
enabled: false
# MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007).
# 이 목록은 항상 한 항목이며, 늘리려면 배포를 하나 더 만든다.
# 주소는 여기서 조립한다. values에 URL을 적기 시작하면 오타가 라우팅 사고가 된다.
bundles:
- id: {{ .Values.deploymentKey | quote }}
namePrefix: {{ $deployment.namePrefix | quote }}
manifestUrl: http://{{ $toolServiceHost }}{{ .Values.toolService.manifestPath }}
baseEndpoint: http://{{ $toolServiceHost }}{{ .Values.toolService.basePath }}
manifestUrl: http://{{ include "mcp-server.toolServiceHost" . }}{{ .Values.toolService.manifestPath }}
baseEndpoint: http://{{ include "mcp-server.toolServiceHost" . }}{{ .Values.toolService.basePath }}
enabled: true
{{- end }}

View File

@@ -1,5 +1,5 @@
{{- include "mcp-server.validate" . }}
{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }}
{{- $tier := index .Values.tiers (include "mcp-server.tier" .) }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -17,12 +17,17 @@ spec:
labels:
{{- include "mcp-server.labels" . | nindent 8 }}
annotations:
# ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 bundle 설정을 고쳐도
# ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 설정을 고쳐도
# 기존 Pod이 옛 설정으로 계속 돌아 배포한 줄 알고 넘어가게 된다.
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
# 진행 중인 tools/call이 잘려 부작용만 남는 것을 줄인다.
terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
{{- with .Values.image.pullSecrets }}
# 사내 registry가 인증을 요구할 때만 지정한다. 비워 두면 렌더링되지 않는다.
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if $tier.spreadAcrossNodes }}
affinity:
podAntiAffinity:
@@ -57,10 +62,20 @@ spec:
value: {{ .Values.redis.port | quote }}
- name: MANAGEMENT_SERVER_PORT
value: {{ .Values.ports.management | quote }}
{{- if .Values.toolService.apiKeySecret.name }}
# Tool Service 호출용 API key. 값은 Secret이 소유하고 Chart는 이름만 안다.
- name: TOOL_SERVER_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.toolService.apiKeySecret.name }}
key: {{ .Values.toolService.apiKeySecret.key }}
{{- end }}
volumeMounts:
- name: config
mountPath: /opt/app/config
readOnly: true
# readiness는 첫 Tool 조회가 끝나고 usable snapshot이 있을 때만 UP이다.
# 원천이 늦게 뜨는 환경에서 Pod을 죽이지 않도록 liveness에는 그 조건이 들어가지 않는다.
readinessProbe:
httpGet:
path: /actuator/health/readiness

View File

@@ -1,10 +1,10 @@
{{- include "mcp-server.validate" . }}
{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }}
{{- $tier := index .Values.tiers (include "mcp-server.tier" .) }}
{{- if $tier.podDisruptionBudget }}
# 중요 등급 배포가 자발적 중단(노드 drain, 클러스터 업그레이드) 중에도 최소 1개를 남기게 한다.
#
# replica를 2 이상으로 올려도 PDB가 없으면 노드 drain이 두 Pod을 한꺼번에 내릴 수 있다.
# 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다(ADR-0007).
# 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다.
#
# NetworkPolicy와 달리 조건이 붙는다. 저쪽은 인가의 전제라 끌 수 없지만 이것은 가용성 정책이고,
# replica 1인 dev에서는 PDB가 오히려 노드 drain을 영구히 막는다.

View File

@@ -1,5 +1,7 @@
{{- include "mcp-server.validate" . }}
{{- $deployment := index .Values.deployments .Values.deploymentKey }}
{{- $deployment := include "mcp-server.selectedDeployment" . | fromYaml }}
# OpenShift Route의 path는 prefix 매칭이다. portal 모드에서 path가 "/mcp"이면
# /mcp/{routeKey} 전체가 이 Route 하나로 들어오고, route 구분은 컨테이너가 한다(ADR-0013).
apiVersion: route.openshift.io/v1
kind: Route
metadata:

View File

@@ -1,10 +1,10 @@
# dev 환경. 배포마다 Pod 1개로 구성한다.
#
# dev에서는 중요 등급도 replica 1이다. rolling update 중 수십 초 공백이 생기지만
# dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 prod에서만 강제하며
# dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 test·prod에서만 강제하며
# HelmDeploymentContractTest가 그 사실을 고정한다.
#
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다.
# 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
global:
@@ -12,6 +12,11 @@ global:
agentBuilderNamespace: ax-hub-agentbuilder-dev
mcpHost: mcp-dev.apps.example.internal
portal:
# TODO: 실제 dev Portal registry 주소로 확정한다. deploy/docker-compose.yml의 mock과 같은 응답을 준다.
registryUrl: https://axhub.devjun.net/api/portal/registry
refreshIntervalSeconds: 60
route:
# TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다.
sourceAllowlist: 192.0.2.0/24

View File

@@ -1,16 +1,14 @@
# prod 환경.
#
# replica는 배포 하나가 받는 트래픽 기준으로 잡는다. 업무 × 등급으로 나뉘어 있으므로
# 배포 하나가 받는 몫은 전체를 하나로 묶었을 때의 일부다. 등급별 기준은 아래가 정본이다.
#
# 조회 부하 = replica 수 / 주기. 1:1이라 bundle 수는 항상 1이다(ADR-0007).
# 중요 등급 3 replica / 30초 = 배포당 초당 0.1회. Tool Service 한 대가 받는 몫이 그대로 이 값이다.
# replica는 배포가 받는 트래픽 기준으로 잡는다.
# 매니페스트 조회 부하 = replica 수 × (route에 붙은 Tool Service 수) / 주기다.
# portal 모드는 한 배포가 전 route를 서비스하므로 route가 늘면 이 값이 함께 는다(ADR-0013 전제 3).
#
# 중요 등급은 replica 2 이상과 PodDisruptionBudget이 필수다.
# 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내린다.
# HelmDeploymentContractTest가 replica·PDB·노드 분산 values를 정적으로 검사한다.
#
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다.
# 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
global:
@@ -18,6 +16,11 @@ global:
agentBuilderNamespace: ax-hub-agentbuilder-prod
mcpHost: mcp.apps.example.internal
portal:
# TODO: 실제 운영 Portal registry 주소로 교체한다.
registryUrl: https://axhub.apps.example.internal/api/portal/registry
refreshIntervalSeconds: 300
route:
# TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다.
sourceAllowlist: 192.0.2.0/24

View File

@@ -1,9 +1,9 @@
# test 환경. 운영계에 앞서 중요 등급의 가용성 설정을 검증하는 단계다.
#
# 중요 등급을 prod와 같은 방식(replica 2 + PDB)으로 먼저 검증하는 자리다.
# 중요 등급을 prod와 같은 방식(replica 2 + PDB + 노드 분산)으로 먼저 검증하는 자리다.
# 여기서 확인하지 않으면 prod 배포 때 처음 겪게 된다.
#
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다.
# 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
global:
@@ -11,6 +11,11 @@ global:
agentBuilderNamespace: ax-hub-agentbuilder-test
mcpHost: mcp-test.apps.example.internal
portal:
# TODO: 실제 test Portal registry 주소로 교체한다.
registryUrl: https://axhub-test.apps.example.internal/api/portal/registry
refreshIntervalSeconds: 300
route:
# TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다.
sourceAllowlist: 192.0.2.0/24

View File

@@ -1,36 +1,53 @@
# 환경 공통 기본값과 배포 토폴로지. 환경별 차이는 values-{env}.yaml이 덮어쓴다.
#
# 내부망 운영은 이 Chart를 사용하지 않는다(ADR-0013 결정 7). 아래 원칙과 deployments 목록은
# 배포 하나가 Tool Service 하나를 보는 mcp.bundles 구성(ADR-0007/0009)을 전제한다.
# Portal이 endpoint 원천인 구성에서는 배포 하나가 N개 route를 서비스하므로 이 토폴로지가 성립하지 않는다.
# 자세한 배경은 deploy/README.md 머리말에 있다.
#
# 이 Chart의 설계 원칙:
# 1. MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007).
# bundle 목록은 항상 한 항목이며 template이 만든다.
# 2. 배포 대상 전체를 아래 deployments 한 곳에 적는다.
# 설치할 때 --set deploymentKey=<key>로 하나를 고른다.
# 배포가 10개든 20개든 파일 수가 늘지 않고, 전체 매핑을 한 화면에서 검토할 수 있다.
# 3. 환경 축(namespace·이미지·등급별 replica)과 배포 축(어느 Tool Service를 보는가)을 섞지 않는다.
# 1. 배포 모델이 두 가지다. mode가 그 축을 고른다.
# portal — route↔Tool Service 매핑의 원천이 Portal이다(ADR-0013). 배포 하나가 N route를
# 서비스하고 route key는 /mcp/{routeKey} URI에서만 온다. 현재 애플리케이션 코드의 경로다.
# bundles — 배포 하나가 Tool Service 하나만 보고 매핑을 배포 시점에 못박는다(ADR-0007).
# ADR-0013이 대체했지만 코드 경로가 남아 있어 1:1 검증·격리 배포에 쓸 수 있다.
# 2. 환경 축(namespace·이미지·등급별 replica)과 배포 축(무엇을 보는가)을 섞지 않는다.
# values-{env}.yaml에는 deployments가 없고, deployments에는 환경 정보가 없다.
# 4. identity는 "{배포 이름}-{global.env}"로 조립한다.
# 3. identity는 "{배포 이름}-{global.env}"로 조립한다.
# Redis key namespace이므로 환경끼리 겹치면 서로 Tool snapshot을 덮어쓴다.
# 사람이 손으로 적지 않게 해 실수를 구조적으로 막는다.
# 5. 외부에서는 환경별 한 host 아래 publicPath로 구분한다. Route는 Service만 선택하고
# 컨테이너가 같은 path를 직접 처리하므로 Registry의 1:1 경계는 바뀌지 않는다(ADR-0009).
# 4. 공개 path는 Route와 컨테이너가 동일하게 사용하고 rewrite하지 않는다(ADR-0009).
# 설치할 배포를 고르는 key. 반드시 --set으로 지정한다.
# 배포 모델. portal | bundles
# 기본값을 portal로 둔 이유는 현재 애플리케이션이 실제로 도는 경로이기 때문이다.
mode: portal
# ---------------------------------------------------------------------------
# mode=portal 축
# ---------------------------------------------------------------------------
portal:
# 이 환경에 설치되는 단일 MCP 배포. route가 늘어도 배포는 늘지 않는다.
deployment:
name: axhub-mcp
tier: critical
# route key는 이 path 아래 URI segment에서 온다. 여기에 routeKey를 적지 않는다.
publicPath: /mcp
# Portal registry 조회 주소. 환경마다 다르므로 values-{env}.yaml이 소유한다.
# 기본값을 두지 않는 이유는, 빠뜨린 설치가 조용히 성공하는 것보다 렌더링 실패가 낫기 때문이다.
registryUrl: ""
refreshIntervalSeconds: 300
# Portal 조회가 실패한 cold start에서만 읽는 Redis fallback key.
# 포털이 registry를 써 넣는 key와 반드시 같아야 한다.
registryRedisKey: axhub:mcp:portal-registry
# ---------------------------------------------------------------------------
# mode=bundles 축
# ---------------------------------------------------------------------------
# 설치할 배포를 고르는 key. mode=bundles일 때 반드시 --set으로 지정한다.
# 기본값을 두지 않는 이유는, 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치되는 것보다
# 렌더링 실패가 낫기 때문이다.
deploymentKey: ""
global:
# 배포 환경. identity 접미사와 NetworkPolicy 판단에 쓰인다.
env: dev
# Agent Builder가 있는 namespace. Route를 우회한 Pod 직접 호출을 이 namespace로 제한한다.
# MCP와 Tool Service는 같은 namespace이므로 여기 적지 않는다.
# TODO: 실제 namespace 확정 시 교체한다.
agentBuilderNamespace: ax-hub-agentbuilder-dev
# Actuator management 포트에 접근할 관제 namespace.
monitoringNamespace: openshift-monitoring
# 환경별 공개 MCP host. 실제 OpenShift apps domain으로 교체한다.
mcpHost: mcp-dev.apps.example.internal
# 배포 대상 전체. map의 key가 곧 bundle id가 된다.
#
# name Deployment/Service/ConfigMap/NetworkPolicy 이름. 같은 namespace에서 유일해야 한다
@@ -39,10 +56,6 @@ global:
# tier 가용성 등급. 아래 tiers의 key여야 한다
# publicPath Agent Builder가 등록할 외부 MCP path. 전체 topology에서 유일해야 한다
#
# 같은 업무의 두 등급이 같은 namePrefix를 공유하는 것은 의도된 구성이다(ADR-0007).
# 등급을 이름에 넣으면 Tool 재분류가 Tool name 변경이 되어 Agent Builder 재등록을 부른다.
# 그 안에서 Tool 이름이 겹치지 않게 하는 것은 Tool Service 책임이다.
#
# TODO: Tool 목록이 확정되면 실제 Tool Service 이름으로 교체하고, 없는 배포는 삭제한다.
deployments:
processing-critical:
@@ -94,6 +107,20 @@ deployments:
tier: standard
publicPath: /mcp/hr-standard
# ---------------------------------------------------------------------------
# 모드 공통
# ---------------------------------------------------------------------------
global:
# 배포 환경. identity 접미사와 NetworkPolicy 판단에 쓰인다.
env: dev
# Agent Builder가 있는 namespace. Route를 우회한 Pod 직접 호출을 이 namespace로 제한한다.
# TODO: 실제 namespace 확정 시 교체한다.
agentBuilderNamespace: ax-hub-agentbuilder-dev
# Actuator management 포트에 접근할 관제 namespace.
monitoringNamespace: openshift-monitoring
# 환경별 공개 MCP host. 실제 OpenShift apps domain으로 교체한다.
mcpHost: mcp-dev.apps.example.internal
# OpenShift Router와 MCP 컨테이너가 같은 publicPath를 사용한다. rewrite하지 않는다.
route:
# Agent Builder 최대 대기 시간과 맞춘 공개 HTTP 연결 timeout이다.
@@ -103,9 +130,9 @@ route:
# 등급별 가용성 기준. 환경별 values가 덮어쓴다.
#
# 배포를 등급으로 나누는 목적이 여기에 있다. 나뉘어 있어야 중요 등급에만 비용을 쓸 수 있다.
# 다만 나누는 것만으로 가용성이 생기지는 않는다. 같은 노드 배치, namespace 쿼터,
# 공통 Redis·클러스터 장애는 분할로 막히지 않는다(ADR-0007).
# 공통 Redis·클러스터 장애는 분할로 막히지 않는다.
# portal 모드에서는 배포가 하나이므로 등급별 물리 분리가 성립하지 않는다(ADR-0013 전제 2).
tiers:
critical:
replicas: 2
@@ -120,21 +147,28 @@ tiers:
image:
# TODO: 사내 컨테이너 registry 경로 확정 시 교체한다.
# CI가 --set image.tag=<commit sha>로 덮어쓴다.
repository: image-registry.openshift-image-registry.svc:5000/ax-hub/ax-hub-mcp-server
tag: "0.1.0"
pullPolicy: IfNotPresent
# 사내 registry가 인증을 요구할 때만 채운다. 예: [{name: harbor-pull}]
pullSecrets: []
mcp:
# Tool Service 매니페스트 조회 주기(초).
# 1:1이라 bundle 수가 항상 1이므로 조회 부하는 (replica 수 / 주기)다.
refreshIntervalSeconds: 30
refreshJitterSeconds: 5
toolService:
# MCP와 같은 namespace에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다.
# bundles 모드에서만 쓴다. MCP와 Tool Service가 같은 namespace라는 전제다.
port: 8080
manifestPath: /tool-manifest
basePath: /mcp
# Tool Service 호출용 API key를 담은 Secret. name이 비어 있으면 환경변수를 주입하지 않고
# 애플리케이션 기본값을 쓴다. 운영에서는 반드시 채운다.
apiKeySecret:
name: ""
key: tool-server-api-key
redis:
host: redis

View File

@@ -53,6 +53,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 |
| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 |
| `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 |
| `ToolSchemaPatternPolicy` | `registry` | `ToolMetadata` 생성 시점에 `pattern` 정규식의 반복 구조·개수·길이와 대상 필드의 `maxLength`를 검사해 정규식 검증이 요청 스레드를 오래 붙잡지 못하게 한다([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)) |
| `ToolSchemaReferencePolicy` | `registry` | `ToolMetadata` 생성 시점에 `inputSchema`가 문서 밖을 참조하지 못하게 차단. 매니페스트가 검증기의 조회 대상을 정하는 통로를 막는다([ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)) |
| `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 시 애플리케이션 생존 |
| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 |
| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping |
@@ -204,9 +206,11 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server e
운영 profile에서는 `ToolBundleDiscovery``ToolBundleRegistryClient`만 metadata 원천으로 활성화한다. MCP 배포별 `mcp.bundles`가 Tool Service의 매니페스트와 실행 주소를 선언한다. 운영 Helm 설정에는 fallback 파일을 넣지 않는다. Tool Service는 표준 `name`을 소유하고, MCP는 자기 Bundle 안에서 형식·설정된 `namePrefix`·중복을 검증하되 이름을 재작성하지 않는다. 서로 다른 MCP 배포 간 이름의 전역 유일성은 Tool Service·플랫폼의 변경 절차가 보장한다. Redis는 선택적인 공유 last-good cache일 뿐 Tool 목록의 원천이 아니다.
**`mcp.bundles`는 N개를 지원하지만 운영 배포에서는 항상 한 항목이다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이다. 그래야 등급이 다른 Tool Service의 조회 실패가 서로의 카탈로그 갱신을 막지 않는다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
**`mcp.bundles` 구성에서 이 목록은 항상 한 항목이다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Java 애플리케이션에는 route table이나 다중 Registry를 추가하지 않는다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다.
이 구성에서 각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다.
**내부망 운영은 위 구성을 쓰지 않는다.** endpoint 목록과 route↔Tool Service 매핑의 원천을 Portal로 옮기고, 배포 하나가 N개 route를 서비스하며 route 하나에 N개 Tool Service가 붙을 수 있다([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). 이때 route key는 `mcp.endpoint-path`에 고정되지 않고 `/mcp/{routeKey}` URI에서 결정되며, 카탈로그 병합과 `max-tools-total` 상한은 route 단위로 적용된다. 배포별 분리가 사라지므로 connection pool·thread·재기동 영향은 전 route가 공유하고, readiness는 route 하나만 준비돼도 UP이 된다. 근거와 포기한 것은 ADR-0013에 있다.
운영 상태는 외부 ingress가 아니라 management port(기본 9090)의 `GET /actuator/toolBundles`로 확인한다.

View File

@@ -7,9 +7,12 @@
- JSON-RPC: `2.0`
- protocolVersion: `2025-11-25`
이 계약의 현재 구현은 stateless MCP 실행 계층의 transport를 동기 JSON으로 고정한다. 현재 in-memory snapshot의 표준 Tool name metadata를 조회해 확정된 endpoint로 POST하며, `Mcp-Session-Id`는 lifecycle correlation 값일 뿐 서버는 initialize 성공 시 이를 발급하지만 대화·readiness 상태를 저장하지 않는다.
이 계약의 현재 구현은 stateless MCP 실행 계층의 transport를 동기 JSON으로 고정한다. 현재 in-memory snapshot의 표준 Tool name metadata를 조회해 확정된 endpoint로 POST하며, `Mcp-Session-Id`는 lifecycle
correlation 값일 뿐 서버는 initialize 성공 시 이를 발급하지만 대화·readiness 상태를 저장하지 않는다.
한 환경은 공개 host를 공유하지만 path마다 독립된 MCP Deployment와 Tool Service에 연결된다. Agent Builder는 각 공개 URL을 별도 MCP로 등록하고 initialize한다. URL 사이에는 session ID, Tool 목록, lifecycle 상태를 공유하지 않는다. Route는 path를 바꾸지 않으며 컨테이너가 같은 path를 처리한다. 이 매핑은 [ADR-0009](../../decisions/ADR-0009-container-handles-public-mcp-path.md)이 정본이며 JSON-RPC payload에는 영향을 주지 않는다.
한 환경은 공개 host를 공유하지만 path마다 독립된 MCP Deployment와 Tool Service에 연결된다. Agent Builder는 각 공개 URL을 별도 MCP로 등록하고 initialize한다. URL 사이에는 session ID, Tool 목록, lifecycle
상태를 공유하지 않는다. Route는 path를 바꾸지 않으며 컨테이너가 같은 path를 처리한다. 이 매핑은 [ADR-0009](../../decisions/ADR-0009-container-handles-public-mcp-path.md)이 정본이며 JSON-RPC payload에는
영향을 주지 않는다.
## HTTP 선택 정책
@@ -18,63 +21,78 @@
- `Accept`는 수용 가능 형식의 선언이며, `text/event-stream`이 포함되어도 응답 transport를 바꾸지 않는다.
- 독립적인 server-push SSE channel은 제공하지 않으므로 공개 endpoint의 `GET``405 Method Not Allowed`다.
- `initialize` 요청에는 `MCP-Protocol-Version` header를 요구하지 않는다.
- `initialize` 이후 `notifications/initialized`, `tools/list`, `tools/call` 요청에는 정확히 `MCP-Protocol-Version: 2025-11-25`이 필수다. `version` 등 임의 header는 대체하지 않는다. header가 없거나 지원하지 않는 값이면 server는 JSON-RPC body 대신 HTTP `400 Bad Request``error`, `message`, `supportedVersions`, `guid`를 가진 JSON 오류 body를 반환한다.
- `initialize` 이후 `notifications/initialized`, `tools/list`, `tools/call` 요청에는 정확히 `MCP-Protocol-Version: 2025-11-25`이 필수다. `version` 등 임의 header는 대체하지 않는다.
header가 없거나 지원하지 않는 값이면 server는 JSON-RPC body 대신 HTTP `400 Bad Request``error`, `message`, `supportedVersions`, `guid`를 가진 JSON 오류 body를 반환한다.
## 호출자 식별 header
`MCP-Protocol-Version` 외에 Agent Builder가 보내는 header는 다섯 개이며 **모두 선택값**이다.
| header | 형식 | 서버 동작 |
|---|---|---|
| `guid` | UUID | 없으면 서버가 생성한다. 응답 header와 오류 body에 되돌려준다 |
| `x-request-id` | 안전 문자 1~128자 | 없으면 서버가 생성한다. 응답 header에 되돌려준다 |
| `mcp-session-id` | 안전 문자 1~128자 | initialize lifecycle 상관 값. 서버는 저장하지 않는다 |
| `employee-no` | 암호화된 사원번호 | 해석하지 않는다 |
| `virtual-employee-no` | 암호화된 가상사원번호 | 해석하지 않는다 |
| header | 형식 | 서버 동작 |
|-----------------------|--------------|-----------------------------------------|
| `guid` | UUID | 없으면 서버가 생성한다. 응답 header와 오류 body에 되돌려준다 |
| `x-request-id` | 안전 문자 1~128자 | 없으면 서버가 생성한다. 응답 header에 되돌려준다 |
| `mcp-session-id` | 안전 문자 1~128자 | initialize lifecycle 상관 값. 서버는 저장하지 않는다 |
| `employee-no` | 사원번호 | 해석하지 않는다 |
| `virtual-employee-no` | 가상사원번호 | 해석하지 않는다 |
사원 식별자 둘은 **불투명 값**이다. MCP는 복호화·검증·저장하지 않고 Tool Service로 그대로 전달한다.
사원 식별자 둘은 **불투명 값**이다. MCP는 검증하지 않고 Tool Service로 그대로 전달한다.
값의 의미는 보지 않되, 개행이나 공백이 섞여 downstream 요청 header를 조작하는 것은 거부한다
(출력 가능 문자 1~2048자가 아니면 `-32600`).
암호화된 값이라도 **로그에 남기지 않는다.** 로그에 나가는 상관 값은 `guid``x-request-id`뿐이다.
## initialize와 notification
`initialize`는 [v0.2 요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 그대로 사용하며, 응답은 [v0.3 응답 예시](examples/agentbuilder-v0.3/initialize-response.json)처럼 원 요청 `id`, `protocolVersion: 2025-11-25`, `serverInfo(name/title/version)`, `capabilities.tools.listChanged: false`를 반환한다. HTTP response header에는 새 UUID `Mcp-Session-Id`가 포함된다. Agent Builder는 응답 version을 이후 모든 HTTP 요청의 `MCP-Protocol-Version` header에 사용하고, session ID를 `notifications/initialized` 및 이후 Tool 요청의 correlation header로 보낸다. MCP 2025-11-25 lifecycle에 따라 Agent Builder는 `notifications/initialized`를 반드시 보내고 두 header를 포함한다. 서버는 notification을 HTTP `202 Accepted`와 빈 body로 수용하되 stateless 원칙상 수신 여부를 저장하거나 이후 요청을 차단하는 readiness gate로 사용하지 않는다.
`initialize`는 [v0.2 요청 예시](examples/agentbuilder-v0.2/initialize-request.json)를 그대로 사용하며, 응답은 [v0.3 응답 예시](examples/agentbuilder-v0.3/initialize-response.json)
처럼 원 요청 `id`, `protocolVersion: 2025-11-25`, `serverInfo(name/title/version)`, `capabilities.tools.listChanged: false`를 반환한다. HTTP response header에는 새 UUID
`Mcp-Session-Id`가 포함된다. Agent Builder는 응답 version을 이후 모든 HTTP 요청의 `MCP-Protocol-Version` header에 사용하고, session ID를 `notifications/initialized` 및 이후 Tool 요청의
correlation header로 보낸다. MCP 2025-11-25 lifecycle에 따라 Agent Builder는 `notifications/initialized`를 반드시 보내고 두 header를 포함한다. 서버는 notification을 HTTP `202 Accepted`
빈 body로 수용하되 stateless 원칙상 수신 여부를 저장하거나 이후 요청을 차단하는 readiness gate로 사용하지 않는다.
## tools/list
`tools/list``result.tools`에 현재 snapshot의 공개 Tool 필드(`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`)를 반환한다. `_meta`의 version, endpoint, HTTP method, timeout, cache 설정은 실행·운영 metadata이므로 MCP 공개 응답에 포함하지 않는다.
`tools/list``result.tools`에 현재 snapshot의 공개 Tool 필드(`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`)를 반환한다. `_meta`의 version,
현재 `tools/call``structuredContent`를 반환하거나 Tool 응답을 `outputSchema`로 검증하지 않는다. 따라서 `outputSchema`를 가진 Tool 정의를 그대로 노출하는 동작은 현재 코드의 사실이지만 MCP 2025-11-25의 구조화 출력 계약을 완전히 충족하지 않는다. 운영 Tool은 구조화 출력 지원이 도입되기 전까지 `outputSchema`를 생략해야 한다.
현재 `tools/call``structuredContent`를 반환하거나 Tool 응답을 `outputSchema`로 검증하지 않는다. 따라서 `outputSchema`를 가진 Tool 정의를 그대로 노출하는 동작은 현재 코드의 사실이지만 MCP 2025-11-25의 구조화 출력
계약을 완전히 충족하지 않는다. 운영 Tool은 구조화 출력 지원이 도입되기 전까지 `outputSchema`를 생략해야 한다.
원천은 profile이 정한다. local은 Tool Service 매니페스트를 먼저 조회하고 최초 실패 시 `config/local-core-tools-manifest-sample-v1.json` fallback을 사용한다(파일이 곧 목록이므로 여기에 Tool 이름을 옮겨 적지 않는다). 운영은 설정된 Tool Service 매니페스트뿐이다.
원천은 profile이 정한다. local은 Tool Service 매니페스트를 먼저 조회하고 최초 실패 시 `config/local-core-tools-manifest-sample-v1.json` fallback을 사용한다(파일이 곧 목록이므로 여기에 Tool 이름을 옮겨 적지
않는다). 운영은 설정된 Tool Service 매니페스트뿐이다.
## 동기 Tool 호출
기본 Tool 호출은 [요청 예시](examples/agentbuilder-v0.3/tools-call-request.json)처럼 `params.name`과 object `params.arguments`를 사용한다. name은 `tools/list`와 실행 사이의 유일한 식별자다. MCP는 snapshot metadata에서 endpoint를 확정하고 arguments 전체를 JSON body로 전달한다. 성공 및 Tool 실행 실패는 각각 [성공 응답](examples/agentbuilder-v0.3/tools-call-success-response.json), [실행 실패 응답](examples/agentbuilder-v0.3/tools-call-execution-error-response.json)처럼 `application/json` JSON-RPC response로 반환한다.
기본 Tool 호출은 [요청 예시](examples/agentbuilder-v0.3/tools-call-request.json)처럼 `params.name`과 object `params.arguments`를 사용한다. name은 `tools/list`와 실행 사이의 유일한 식별자다.
MCP는 snapshot metadata에서 endpoint를 확정하고 arguments 전체를 JSON body로 전달한다. 성공 및 Tool 실행 실패는
각각 [성공 응답](examples/agentbuilder-v0.3/tools-call-success-response.json), [실행 실패 응답](examples/agentbuilder-v0.3/tools-call-execution-error-response.json)처럼
`application/json` JSON-RPC response로 반환한다.
성공 응답은 Tool의 plain text를 `result.content[0].text`, 소요 시간(ms)을 `result.content[0]._meta.searchTime`, 성공 여부를 `result.isError: false`에 넣는다. JSON object/array 응답은 compact JSON 문자열로 `text`에 보존하며, outer JSON serializer가 올바른 quote escaping을 수행한다. 실행·timeout·권한 실패는 `result.isError: true`이며, JSON-RPC envelope/params/method 오류는 기존 JSON-RPC `error`다. Registry의 `inputSchema`는 모든 `tools/call`에서 Tool 호출 전에 검증한다.
성공 응답은 Tool의 plain text를 `result.content[0].text`, 소요 시간(ms)을 `result.content[0]._meta.searchTime`, 성공 여부를 `result.isError: false`에 넣는다. JSON object/array 응답은
compact JSON 문자열로 `text`에 보존하며, outer JSON serializer가 올바른 quote escaping을 수행한다. 실행·timeout·권한 실패는 `result.isError: true`이며, JSON-RPC envelope/params/method 오류는
기존 JSON-RPC `error`다. Registry의 `inputSchema`는 모든 `tools/call`에서 Tool 호출 전에 검증한다.
## tools/call 성공·오류 응답 기준
Agent Builder는 HTTP 상태만으로 성공 여부를 판단하지 않고 JSON-RPC body의 최상위 `result` 또는 `error`를 확인해야 한다. 일반적인 JSON-RPC 요청 오류는 HTTP `200 OK`와 함께 최상위 `error`로 반환될 수 있다. `-32602``error.message``Invalid params: <상세 원인>` 형식이며, 예를 들어 필수 `query`가 없으면 `Invalid params: 'query' is required`를 반환한다. 선택적인 `error.data`에는 `guid`와 상세 원인을 추가로 담을 수 있다. 단, `MCP-Protocol-Version` 누락·미지원처럼 HTTP transport 단계에서 거부된 요청은 HTTP `400 Bad Request`다.
Agent Builder는 HTTP 상태만으로 성공 여부를 판단하지 않고 JSON-RPC body의 최상위 `result` 또는 `error`를 확인해야 한다. 일반적인 JSON-RPC 요청 오류는 HTTP `200 OK`와 함께 최상위 `error`로 반환될 수 있다. `-32602`
`error.message``Invalid params: <상세 원인>` 형식이며, 예를 들어 필수 `query`가 없으면 `Invalid params: 'query' is required`를 반환한다. 선택적인 `error.data`에는 `guid`와 상세 원인을 추가로 담을
수 있다. 단, `MCP-Protocol-Version` 누락·미지원처럼 HTTP transport 단계에서 거부된 요청은 HTTP `400 Bad Request`다.
| 상황 | HTTP 상태 | JSON-RPC body | `isError` | 현재 구현의 처리 주체 |
|---|---:|---|---|---|
| Tool 정상 완료 | 200 | `result.content` | 반드시 `false` | `ToolsCallHandler` |
| Tool Service timeout, upstream 4xx/5xx, downstream 권한 거부 | 200 | `result.content` | 반드시 `true` | `ToolsCallHandler` |
| Tool이 실행된 뒤 업무 검증·업무 규칙으로 실패 | 200 | `result.content` | 반드시 `true` | Tool Service 또는 실행 계층 |
| JSON 문법 오류 | 200 | 최상위 `error` (`-32700`) | 없음 | `McpExceptionHandler` |
| JSON-RPC envelope 오류 | 200 | 최상위 `error` (`-32600`) | 없음 | `JsonRpcRequestParser` |
| 알 수 없는 MCP method 또는 Tool | 200 | 최상위 `error` (`-32601` 또는 Tool 조회 오류) | 없음 | method/registry 계층 |
| `params.name` 누락, `params.arguments` 형식 오류, 공개된 inputSchema의 필수 값 누락 | 200 | 최상위 `error` (`-32602`) | 없음 | Adapter/parameter/schema validator |
| 서버 설정·Registry 장애 등 서버가 Tool 호출을 시작할 수 없는 경우 | 200 | 최상위 `error` (`-32603` 또는 서버 정의 오류) | 없음 | transport/execute 계층 |
| `MCP-Protocol-Version` 누락 또는 미지원 | 400 | transport 오류 body | 없음 | `McpProtocolVersionValidator` |
| 상황 | HTTP 상태 | JSON-RPC body | `isError` | 현재 구현의 처리 주체 |
|----------------------------------------------------------------------|--------:|--------------------------------------|-------------|------------------------------------|
| Tool 정상 완료 | 200 | `result.content` | 반드시 `false` | `ToolsCallHandler` |
| Tool Service timeout, upstream 4xx/5xx, downstream 권한 거부 | 200 | `result.content` | 반드시 `true` | `ToolsCallHandler` |
| Tool이 실행된 뒤 업무 검증·업무 규칙으로 실패 | 200 | `result.content` | 반드시 `true` | Tool Service 또는 실행 계층 |
| JSON 문법 오류 | 200 | 최상위 `error` (`-32700`) | 없음 | `McpExceptionHandler` |
| JSON-RPC envelope 오류 | 200 | 최상위 `error` (`-32600`) | 없음 | `JsonRpcRequestParser` |
| 알 수 없는 MCP method 또는 Tool | 200 | 최상위 `error` (`-32601` 또는 Tool 조회 오류) | 없음 | method/registry 계층 |
| `params.name` 누락, `params.arguments` 형식 오류, 공개된 inputSchema의 필수 값 누락 | 200 | 최상위 `error` (`-32602`) | 없음 | Adapter/parameter/schema validator |
| 서버 설정·Registry 장애 등 서버가 Tool 호출을 시작할 수 없는 경우 | 200 | 최상위 `error` (`-32603` 또는 서버 정의 오류) | 없음 | transport/execute 계층 |
| `MCP-Protocol-Version` 누락 또는 미지원 | 400 | transport 오류 body | 없음 | `McpProtocolVersionValidator` |
모든 routing은 공통 `name`/`arguments` 형식과 선택된 Tool의 `inputSchema`를 실행 전에 검증한다. Tool Service가 반환한 HTTP 400은 검증을 통과해 Tool 실행을 시작한 뒤의 실패이므로 `result.isError: true` 반환한다.
모든 routing은 공통 `name`/`arguments` 형식과 선택된 Tool의 `inputSchema`를 실행 전에 검증한다. Tool Service가 반환한 HTTP 400은 검증을 통과해 Tool 실행을 시작한 뒤의 실패이므로 `result.isError: true`
반환한다.
실행 가능한 응답 형태는 [성공 예시](examples/agentbuilder-v0.3/tools-call-success-response.json), [Tool 실행 실패 예시](examples/agentbuilder-v0.3/tools-call-execution-error-response.json), [잘못된 인자 예시](examples/agentbuilder-v0.3/tools-call-invalid-params-response.json)를 따른다.
실행 가능한 응답
형태는 [성공 예시](examples/agentbuilder-v0.3/tools-call-success-response.json), [Tool 실행 실패 예시](examples/agentbuilder-v0.3/tools-call-execution-error-response.json), [잘못된 인자 예시](examples/agentbuilder-v0.3/tools-call-invalid-params-response.json)
를 따른다.
## 호환성 메모

View File

@@ -0,0 +1,51 @@
# Portal-MCP 계약 문서
이 디렉터리는 Portal과 MCP Server 사이의 **Tool Server endpoint 목록 조회 계약**을 관리한다.
```text
Portal ──[portal-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Service
(endpoint 목록) (Tool 목록과 실행)
```
| 문서 | 상태 | 용도 |
|---|---|---|
| [protocol-v0.1-registry.md](protocol-v0.1-registry.md) | MCP 측 구현 완료, Portal 측 미합의 | Portal registry 조회 요청·응답과 실패 처리 계약 |
## 이 계약이 존재하는 이유
`mcp.bundles`로 배포 YAML에 Tool Service를 직접 선언하는 구성에서는 이 계약이 필요 없다.
Portal이 route별 Tool Server 목록을 관리하는 구성(`mcp.portal.enabled=true`)에서만 사용하며,
이때 Portal은 **endpoint 목록의 원천**이 된다.
**내부망 운영은 이 구성을 채택했다**([ADR-0013](../../decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)).
따라서 이 계약은 선택 사항이 아니라 운영 경로의 정본이다. 배포 하나가 N개 route를 서비스하고
route 하나에 N개 Tool Service가 붙을 수 있다.
## 현재 원칙
- Portal은 **어디에 Tool Server가 있는가**만 답한다. **어떤 Tool이 있는가**는 여전히 Tool Service 매니페스트가 답한다.
- MCP는 Portal registry와 Tool Service 매니페스트를 **서로 다른 주기로** 조회한다.
- Portal 조회 실패는 목록을 비우지 않는다. in-memory endpoint snapshot을 유지하고, cold start일 때만 Redis fallback을 읽는다.
- 요청 경로(`tools/list`, `tools/call`)는 Portal을 호출하지 않는다. in-memory snapshot만 읽는다.
- **이 구성에서 outbound 주소의 원천은 배포 YAML이 아니라 Portal이다.** 따라서 Portal은 신뢰 경계 안에 있어야 하며,
MCP→Portal 구간은 network 수준에서 제한한다. 근거와 요구사항은 [v0.1 계약 §8](protocol-v0.1-registry.md#8-보안-요구사항)에 있다.
## 예제와 검증
[examples/registry-v0.1](examples/registry-v0.1/)의 응답 JSON을 `PortalRegistryContractExampleTest`가 직접 읽어
`PortalToolRegistryClient`의 실제 파싱 경로에 태운다. 예제와 구현은 같은 변경에서 함께 고친다.
## 현재 producer는 외부망 검증용 PoC다
운영 Portal은 아직 이 API를 제공하지 않는다. 현재 응답을 만드는 것은 외부망 통합 검증용 PoC Portal이며,
이 계약 문서가 **PoC와 운영 Portal이 공유해야 할 유일한 정본**이다.
PoC 구현이 저장소를 떠나도 이 문서와 예제는 남는다.
운영 적용 전에 Portal 개발 파트와 다음 항목을 확정한다.
1. Portal registry API의 인증 방식과 MCP→Portal NetworkPolicy
2. Tool Service 매니페스트 조회용 credential 전달 경로 (현재 registry 응답에 없다, §9)
3. `registryRevision` 채번 주체와 단조 증가 보장 범위
4. route key 명명 규칙과 route 삭제 시 rolling 절차
상세 필드와 실패 처리는 [v0.1 계약](protocol-v0.1-registry.md)을 따른다.

View File

@@ -0,0 +1,41 @@
{
"registryRevision": 12,
"routes": [
{
"routeKey": "business",
"toolServices": [
{
"serviceKey": "business-tools",
"displayName": "Business Tool Server",
"serviceDomain": "http://tool-business.ax-hub.svc.cluster.local:8080",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "business.",
"toolEndpoints": {
"business.customer_search": "/mcp/business.customer_search",
"business.order_status": "/mcp/business.order_status"
},
"status": "ACTIVE"
}
]
},
{
"routeKey": "external",
"toolServices": [
{
"serviceKey": "external-tools",
"displayName": "External Tool Server",
"serviceDomain": "http://tool-external.ax-hub.svc.cluster.local:8080",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "external.",
"toolEndpoints": {
"external.exchange_rate": "/mcp/external.exchange_rate",
"external.weather_lookup": "/mcp/external.weather_lookup"
},
"status": "ACTIVE"
}
]
}
]
}

View File

@@ -0,0 +1,48 @@
# MCP Server의 Portal registry 설정 예시 (protocol-v0.1-registry.md 3절)
#
# 이 파일은 계약 예시이며 실제 적용 설정이 아니다.
# 운영에서는 ConfigMap으로 주입한다.
#
# 키 이름은 구현된 McpProperties와 1:1로 맞춰 두었다. Spring relaxed binding이
# camelCase와 kebab-case를 모두 받으므로 이 문서는 application.yml과 같은 kebab-case를 쓴다.
mcp:
portal:
# true일 때만 PortalToolRegistryClient가 등록된다.
# false면 아래 bundles 목록이 endpoint 원천이 된다.
enabled: true
# 반드시 집계 조회 endpoint를 가리킨다. route별 URL이나 {route} placeholder를 쓰지 않는다.
# 이유는 계약 2절에 있다.
registry-url: http://portal.ax-hub.svc.cluster.local:8080/api/portal/registry
# Portal registry 조회 주기. 매니페스트 조회 주기(mcp.registry)와 분리된다.
# registryRevision이 바뀌면 이 주기와 별개로 매니페스트 refresh가 즉시 한 번 더 돈다.
refresh-interval-seconds: 300
registry:
# 저장된 endpoint의 Tool 매니페스트를 다시 읽는 주기.
refresh-interval-seconds: 30
refresh-jitter-seconds: 5
discovery:
# Portal 구성에서도 매니페스트 조회·검증 경로는 그대로 사용한다.
enabled: true
connect-timeout-millis: 1000
read-timeout-millis: 3000
max-tools-per-bundle: 100
max-tools-total: 200
max-manifest-bytes: 1048576
max-tool-timeout-millis: 30000
redis:
enabled: true
key-prefix: axhub:mcp
# Portal registry 응답 JSON의 fallback key.
# route별 Tool snapshot key와 반드시 분리한다(계약 7절).
# 운영에서는 Portal이 쓰는 key와 값을 맞춘다.
portal-registry-key: axhub:mcp:portal-registry
# Portal이 endpoint 원천이므로 이 목록은 비운다.
# 원천이 둘이 되면 어느 쪽이 이겼는지 로그로 구분할 수 없다.
bundles: []

View File

@@ -0,0 +1,19 @@
{
"routeKey": "external",
"registryRevision": 12,
"toolServices": [
{
"serviceKey": "external-tools",
"displayName": "External Tool Server",
"serviceDomain": "http://tool-external.ax-hub.svc.cluster.local:8080",
"manifestPath": "/tool-manifest",
"executeBasePath": "",
"namePrefix": "external.",
"toolEndpoints": {
"external.exchange_rate": "/mcp/external.exchange_rate",
"external.weather_lookup": "/mcp/external.weather_lookup"
},
"status": "ACTIVE"
}
]
}

View File

@@ -0,0 +1,227 @@
# 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-0013](../../decisions/ADR-0013-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 주소로 치환하며,
나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**

View File

@@ -179,7 +179,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 |
| `title` | 아니오 | 표시용 이름 |
| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 |
| `inputSchema` | 예 | JSON Schema 2020-12 |
| `inputSchema` | 예 | JSON Schema 2020-12. 아래 **schema 제약**을 만족해야 한다 |
| `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 |
| `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` |
| `_meta.version` | 예 | Tool 버전 |
@@ -189,6 +189,24 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`는 MCP가 `tools/list`
그대로 공개한다. `_meta`는 공개하지 않는다.
#### schema 제약
MCP는 `inputSchema`를 검증기에 넘기기 전에 다음을 확인하고, 어기면 그 Tool이 실린 bundle을 실패로 처리한다.
매니페스트 형식 오류와 같은 취급이므로 다른 bundle의 정상 Tool은 영향을 받지 않는다.
| 제약 | 내용 | 근거 |
|---|---|---|
| 문서 안 참조만 | `$ref`·`$dynamicRef``#`으로 시작해야 한다. 공통 타입은 같은 문서의 `$defs`에 둔다 | [ADR-0011](../../decisions/ADR-0011-tool-input-schema-stays-in-document.md) |
| dialect 고정 | `$schema`를 선언하면 `https://json-schema.org/draft/2020-12/schema`여야 한다 | ADR-0011 |
| 정규식 반복 | 무한 수량자(`*`, `+`, `{n,}`)를 품은 그룹을 다시 반복할 수 없다. 바깥 반복 횟수가 유한해도 같다 | [ADR-0012](../../decisions/ADR-0012-tool-input-schema-pattern-budget.md) |
| 정규식 수량자 | 무한 수량자는 정규식 하나당 3개까지 | ADR-0012 |
| 정규식 길이 | `pattern`은 512자 이하이며 컴파일 가능해야 한다 | ADR-0012 |
| 길이 상한 동반 | `pattern`을 선언한 필드는 `maxLength`를 함께 선언해야 하고 값은 256 이하 | ADR-0012 |
| `patternProperties` 금지 | 이 keyword는 사용할 수 없다. 고정 key를 `properties`로 선언한다 | ADR-0012 |
마지막 항목이 가장 자주 걸린다. `{"type":"string","pattern":"^[0-9]{10}$"}`는 거부되고
`{"type":"string","maxLength":10,"pattern":"^[0-9]{10}$"}`는 통과한다.
현재 MCP의 `tools/call``content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다.
MCP 2025-11-25에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는
구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다.

View File

@@ -1,9 +1,14 @@
# ADR-0007 MCP 배포 하나는 Tool Service 하나만 본다
- 상태: Accepted
- 상태: Superseded
- 결정일: 2026-08-02
- 대체 결정: [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)
- 관련: [ADR-0001](ADR-0001-stateless-execution-boundary.md), [ADR-0002](ADR-0002-tool-exposure-and-single-call.md), [ADR-0009](ADR-0009-container-handles-public-mcp-path.md), [계약 v0.2](../contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md)
> 이 문서는 당시 검토 이력을 보존한다. 내부망 운영은 endpoint 목록과 route 매핑의 원천을 Portal로 옮겼으므로
> 현재 구현과 신규 연동에는 [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)을 적용한다.
> 아래 격리 논거는 폐기된 것이 아니라 ADR-0013이 무엇을 포기했는지 판단하는 근거로 남는다.
외부에서 여러 MCP를 하나의 host 아래 path로 묶는 방식은 [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)이
소유한다. OpenShift Route가 원래 path를 유지한 채 각각의 독립 배포로 연결하므로 이 ADR의 1:1 결정은 그대로 유지된다.

View File

@@ -3,6 +3,7 @@
- 상태: Accepted
- 결정일: 2026-08-05
- 대체: [ADR-0008](ADR-0008-shared-host-path-routing.md)
- 부분 대체됨: 결정 4는 [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)이 대체한다
- 관련: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
## 배경

View File

@@ -0,0 +1,72 @@
# ADR-0011 Tool inputSchema는 문서 밖을 참조하지 않는다
- 상태: Accepted
- 결정일: 2026-08-18
- 관련 결정: [ADR-0006](ADR-0006-no-authentication-in-mcp.md) · [ADR-0004](ADR-0004-execution-guardrails.md)
## 배경
MCP Java SDK를 도입하면서 JSON Schema 2020-12 검증을 `com.networknt:json-schema-validator`에 위임했다
([mcp-java-sdk-adoption.md](../mcp-java-sdk-adoption.md)). 그런데 JSON Schema의 `$ref`는 같은 문서 안뿐 아니라
**다른 주소의 문서**를 가리킬 수 있고, 검증기는 그런 참조를 만나면 그 주소로 직접 조회를 시도한다.
`inputSchema`는 Tool Service 매니페스트에서 온다. 즉 매니페스트에 이런 schema가 실리면
```json
{"type":"object","properties":{"q":{"$ref":"http://any-host/whatever.json"}}}
```
MCP가 그 주소로 요청을 보낸다. 이것은 [AGENTS.md §2](../../AGENTS.md)의 불변식과 정면으로 어긋난다.
> outbound 주소는 설정에서만 온다. 요청 값도 매니페스트도 호출 대상을 바꾸지 못한다.
매니페스트가 선언한 `endpoint`를 무시하는 규칙은 이미 있고 테스트로 잠겨 있다. `$ref`는 같은 불변식을
같은 방식으로 깨는데 통제가 없던 경로였다. SDK 도입이 열어 놓은 구멍이다.
[ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라 MCP는 인증·인가를 하지 않으므로, 이 경로 앞에서
호출자를 걸러 주는 계층도 없다.
## SDK 설정으로는 막을 수 없다
`DefaultJsonSchemaValidator``SchemaRegistry`를 생성자 안에서 직접 만들고 `private final`로 들고 있다.
공개 생성자는 `()``(ObjectMapper)` 둘뿐이라, 참조 해석 정책을 담은 설정을 밖에서 넣을 자리가 없다.
검증기 쪽에서 끄는 선택지는 존재하지 않는다.
## 결정
**Tool의 `inputSchema`는 문서 밖을 가리키는 참조를 담을 수 없다.** 검증기에 넘기기 전에, schema가 Registry로
들어오는 시점에 거부한다.
| 대상 | 규칙 |
|---|---|
| `$ref`, `$dynamicRef` | 값이 `#`으로 시작해야 한다. 즉 같은 문서 안의 위치만 가리킨다 |
| `$schema` | 선언했다면 `https://json-schema.org/draft/2020-12/schema`여야 한다 |
| `$id` | 제한하지 않는다 |
`$id`를 열어 두는 이유는, 문서 밖 참조가 모두 막히면 base URI가 무엇이든 조회가 일어나지 않기 때문이다.
막을 이유가 없는 것까지 막으면 정상 Tool만 거부된다.
검사 지점은 `ToolMetadata`의 표준 생성자다. Portal 매니페스트 파싱, local 파일 로딩, Redis snapshot 역직렬화가
모두 이 생성자를 지나므로 **경로마다 검사를 흩어 놓지 않아도 우회 경로가 생기지 않는다.**
위반은 기존 매니페스트 형식 오류와 같게 다룬다. 따라서 bundle 단위 실패 격리와 "Redis 실패는 언제나 cache miss"
불변식이 그대로 적용되고, 한 Tool의 잘못된 schema가 다른 bundle의 정상 Tool을 지우지 않는다.
## 검토한 대안
| 대안 | 채택하지 않은 이유 |
|---|---|
| 검증기 설정으로 원격 해석 차단 | 위 절대로 주입 지점이 없다 |
| `JsonSchemaValidator`를 직접 구현 | SDK에 표준 검증을 위임한다는 도입 전제를 되돌리게 된다. networknt API를 우리가 떠안고, SDK 업그레이드마다 정책이 조용히 어긋날 수 있다 |
| egress 방화벽만으로 차단 | 심층 방어로는 유효하지만 단독으로는 부족하다. 플랫폼 설정에 의존하고, 차단되지 않은 내부 주소에는 여전히 도달한다 |
egress 통제는 이 결정을 대체하지 않고 함께 둔다.
## 영향
- Tool Service는 `inputSchema`를 자기 문서 안에서 완결시켜야 한다. 공통 타입은 `$defs`로 같은 문서에 넣고
`#/$defs/...`로 참조한다. 이 항목은 합의 대상이 아니라 계약이므로
[extension-points.md](../extension-points.md)의 협의 목록에서 뺀다.
- `format` 키워드의 검증 강도와 허용 keyword 범위는 여전히 미확정이며 협의 목록에 남는다.
- 새 참조 keyword가 JSON Schema에 추가되면 이 결정을 함께 갱신한다. 규칙은
`ToolSchemaReferencePolicy`가 소유하고 `ToolSchemaReferencePolicyTest`가 잠근다.

View File

@@ -0,0 +1,88 @@
# ADR-0012 Tool inputSchema의 정규식에 예산을 둔다
- 상태: Accepted
- 결정일: 2026-08-18
- 관련 결정: [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) · [ADR-0006](ADR-0006-no-authentication-in-mcp.md) · [ADR-0004](ADR-0004-execution-guardrails.md)
## 배경
`inputSchema``pattern` 검증은 `java.util.regex`로 처리된다. `com.networknt:json-schema-validator`
ECMAScript 엔진은 joni나 graal-js가 있을 때만 쓰이는데 둘 다 해석하지 않으므로
([SBOM](../sbom/README.md)), 기본 경로인 `JDKRegularExpression``Pattern.compile``Matcher.find()`
호출한다. `matches()`가 아니라 `find()`라서 모든 시작 위치를 시도한다.
이 엔진은 백트래킹 기반이라 정규식과 입력의 조합에 따라 처리 시간이 폭증한다. 정규식은 Tool Service
매니페스트에서 오고 입력은 Agent Builder에서 오며, [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라
호출자를 걸러 주는 계층이 없다. 한 요청이 스레드를 붙잡으면 그대로 Tomcat 스레드 고갈로 이어진다.
## 측정
규칙을 감으로 정하지 않기 위해 JDK 21.0.11에서 직접 재어 보았다. 3초 안에 끝나지 않으면 HANG으로 적었다.
| 정규식 | n=100 | n=1000 | n=10000 |
|---|---|---|---|
| `a*a*b` (무한 수량자 2개) | 4ms | 481ms | **HANG** |
| `a*a*a*b` (3개) | 17ms | **HANG** | **HANG** |
| `a*a*a*a*b` (4개) | 432ms | **HANG** | **HANG** |
| `a*a*a*a*a*b` (5개) | **HANG** | **HANG** | **HANG** |
| `(.*,){11}P` | **HANG** | **HANG** | **HANG** |
| `(x+x+)+y` | 5ms | **HANG** | **HANG** |
| `^[^@ ]+@[^@ ]+$` (무한 수량자 2개) | 4ms | 4ms | **4ms** |
| `^([A-Z]{3}-)+[0-9]+$` | 1ms | 1ms | 1ms |
두 가지가 드러났다.
**첫째, 교과서적인 중첩 수량자는 생각보다 덜 위험하고 다른 형태가 더 위험하다.** `^(a+)+$`는 n=60에서도
0ms로 끝났다. 반면 중첩이 아닌 `a*a*a*a*a*b`는 n=100에서 이미 멈췄고, 바깥 반복이 11회로 **묶여 있는**
`(.*,){11}P`도 멈췄다. "중첩된 무한 수량자만 막으면 된다"는 통념대로 짰다면 정작 위험한 것을 놓쳤을 것이다.
**둘째, 개수만으로는 가를 수 없다.** `a*a*b``^[^@ ]+@[^@ ]+$`는 둘 다 무한 수량자가 2개인데 전자는
멈추고 후자는 n=10000에서도 4ms다. 차이는 수량자가 **겹치는 문자 집합**에 걸리느냐다. `@`가 경계를 만들면
되돌아갈 여지가 없다. 겹침 판정은 정적 분석 대상이고 일반적으로 결정 불가능하다.
## 결정
정규식 모양만으로는 안전을 가릴 수 없으므로, **가릴 수 있는 것은 모양으로 막고 나머지는 입력 길이로 묶는다.**
검사는 `ToolSchemaPatternPolicy``ToolMetadata` 생성 시점에 수행한다.
| 규칙 | 내용 | 근거 |
|---|---|---|
| 그룹 반복 | 무한 수량자를 품은 그룹을 다시 반복하면 거부. 바깥 반복 횟수에 상한이 있어도 거부 | `(x+x+)+y`, `(.*,){11}P` |
| 수량자 개수 | 무한 수량자 4개 이상이면 거부 | 4개는 n=1000, 5개는 n=100에서 멈춤 |
| 길이 상한 | `pattern`을 선언한 필드는 `maxLength`를 함께 선언해야 하고 256 이하여야 함 | 비용이 입력 길이를 따라 늘어남 |
| 정규식 길이 | 512자 이하 | 분석 비용을 함께 묶음 |
| 컴파일 | 등록 시점에 `Pattern.compile` | 잘못된 정규식이 요청 시점에 터지지 않게 |
| `patternProperties` | 사용 금지 | 아래 참조 |
`maxLength` 요구가 이 결정의 핵심이다. 나머지 규칙은 겹침을 판정하지 못하므로, 실질적인 상한은 길이 제한이
만든다. 상한이 없으면 요청 body 한도(약 1MB)까지 열린다.
한도 값은 설정으로 열지 않고 상수로 둔다. [ADR-0006](ADR-0006-no-authentication-in-mcp.md)의 NetworkPolicy와
같은 이유다. values 한 줄로 사라질 수 있는 통제는 통제가 아니다.
## 이 결정이 하지 않는 것
**안전을 증명하지 않는다.** 무한 수량자 3개 이하이면서 문자 집합이 겹치는 정규식은 통과하고, `maxLength`
256이면 그 조합에서 수백 ms가 걸릴 수 있다. 이 결정은 위험을 없애지 않고 **측정된 폭증 구간 밖으로 옮긴다.**
근본적인 해결은 백트래킹하지 않는 엔진(RE2 계열)으로 바꾸거나 검증에 시간 예산을 두는 것이다. 둘 다 지금
채택하지 않았다. 전자는 폐쇄망 반입 대상 의존성이 늘고 networknt가 그 엔진을 지원하는지 확인해야 하며,
후자는 `java.util.regex`가 인터럽트에 반응하지 않아 검증기 내부에 우리 `CharSequence`를 넣을 수 없으면
스레드를 버리는 방식이 된다. 필요가 생기면 이 ADR을 대체하는 새 ADR을 먼저 쓴다.
## 영향
- **Tool Service는 `pattern`을 쓰는 문자열 필드에 `maxLength`(≤256)를 함께 선언해야 한다.** 이는 매니페스트
수용 조건의 변경이므로 Tool Service 파트와 합의가 필요하다. 현재 저장소의 schema 중 `pattern`을 쓰는 것은
없어 기존 fixture는 영향을 받지 않는다.
- **`patternProperties`는 쓸 수 없다.** 이 keyword는 값이 아니라 입력 객체의 **key**에 정규식을 적용하는데,
key에는 길이를 선언할 자리가 없어 위의 `maxLength` 방식을 그대로 적용할 수 없다. `propertyNames`로 key 길이를
묶는 방법을 검토했으나, JSON Schema는 keyword 평가 순서를 정하지 않으므로 `propertyNames`가 먼저 돈다는
보장이 없다. 순서에 기대는 통제는 검증기 구현이 바뀌면 조용히 사라진다.
현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 위험을 남겨 두는 대신 쓰지 않는 기능을 닫는다.
이는 [ADR-0006](ADR-0006-no-authentication-in-mcp.md)이 검증하지 않는 인증 코드를 지운 것과 같은 판단이다.
동적 key가 실제로 필요해지면 key 길이를 묶는 방법을 정한 새 ADR을 먼저 쓴다. 부작용으로, `patternProperties`
라는 이름의 업무 필드를 가진 schema도 거부된다. 실제로 나타날 가능성이 낮아 감수한다.
- 규칙과 한도는 `ToolSchemaPatternPolicy`가 소유하고 `ToolSchemaPatternPolicyTest`가 잠근다. 위 측정을 다시
하지 않고 한도를 바꾸지 않는다.

View File

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

View File

@@ -18,6 +18,9 @@
| [ADR-0004](ADR-0004-execution-guardrails.md) | 300초, Raw Data, unsafe retry 실행 가드레일 | Accepted |
| [ADR-0005](ADR-0005-standard-tool-name.md) | 표준 MCP Tool name을 실행 식별자로 사용 | Accepted |
| [ADR-0006](ADR-0006-no-authentication-in-mcp.md) | MCP Server는 인증·인가를 하지 않는다 | Accepted |
| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Accepted |
| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Superseded |
| [ADR-0008](ADR-0008-shared-host-path-routing.md) | 공유 host의 path를 독립 MCP 배포로 연결 | Superseded |
| [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) | 컨테이너가 공개 MCP path를 직접 처리 | Accepted |
| [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) | Tool inputSchema는 문서 밖을 참조하지 않는다 | Accepted |
| [ADR-0012](ADR-0012-tool-input-schema-pattern-budget.md) | Tool inputSchema의 정규식에 예산을 둔다 | Accepted |
| [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md) | Tool Server endpoint 목록과 route 매핑의 원천은 Portal | Accepted |

View File

@@ -34,7 +34,13 @@
1. `GET {manifestUrl}` 제공, 인증 방식과 NetworkPolicy 범위
2. Tool name namespace, 변경·폐기 절차와 하위 호환 기간
3. 허용할 JSON Schema 2020-12 keyword, 원격 `$ref``format` 정책
3. 허용할 JSON Schema 2020-12 keyword와 `format` 정책. **현재 SDK 검증기는 `format`을 단언하지 않는다.**
`format: "date-time"`에 아무 문자열이나 넣어도 통과하므로, Tool Service가 이를 입력 검증 수단으로
기대하면 안 된다. 단언을 켤지, 아니면 `pattern`으로 대체할지 정해야 한다. 현재 동작은
`ToolArgumentValidatorTest`가 고정한다. 문서 밖 `$ref`
[ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)로, `pattern`의 반복 예산과
`maxLength` 동반 선언 요구는 [ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)로
확정했다. **ADR-0012는 매니페스트 수용 조건을 바꾸므로 Tool Service 파트와 합의가 필요하다**
4. Tool별 timeout, 권한 scope, write Tool의 idempotency 보장
5. `outputSchema`/`structuredContent` 도입 여부와 응답 검증 실패 의미
6. 매니페스트 revision·ETag/304 및 즉시 refresh 알림의 필요성
@@ -54,7 +60,10 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one
## 플랫폼·DevOps와 확인할 항목
배포 정의를 이 저장소가 어디까지 소유하는지 확정되지 않았다.
현재는 [Helm Chart](../deploy/helm/mcp-server/)만 두고 있으며, 빌드·배포 실행 방식은 정의하지 않는다.
GitOps 저장소도 ArgoCD Application도 아직 없어, 그때까지 [Helm Chart](../deploy/helm/mcp-server/)
push 방식 파이프라인(`.gitea/workflows/`)을 이 저장소가 **임시로** 소유한다.
그 방식이 무엇을 포기하는지와 넘길 때 할 일은
[deploy/README.md](../deploy/README.md#gitops-저장소가-없는-동안의-우회)가 정본이다.
1. **Helm Chart를 어디에 두는가.** 앱 저장소인가 배포 전용 저장소인가
2. 환경별 namespace 명명 규칙과 Agent Builder namespace.
@@ -67,6 +76,15 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one
6. 환경별 실제 `global.mcpHost`, 인증서와 TLS termination 책임
7. Agent Builder의 실제 고정 egress CIDR과 Route `ip_allowlist`
8. 대상 OpenShift의 ingress namespace label과 IngressController endpoint publishing 방식이 Chart의 NetworkPolicy 전제와 맞는지
9. **GitOps 저장소와 ArgoCD Application의 소유 주체와 생성 시점.** 그때까지 파이프라인이 클러스터
자격증명(`OCP_SERVER`·`OCP_TOKEN`)을 들고 있어야 하므로, 러너를 신뢰 경계 안에 두는 것이 전제다
10. **운영 배포 모델을 `portal`로 확정하는가.** Chart는 `portal``bundles`를 모두 렌더링하지만
[ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)이
[ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)을 대체했다. `bundles` 경로를 언제 삭제할지
11. 사내 registry 주소·인증 방식과 `imagePullSecrets`에 넣을 Secret 이름
12. `TOOL_SERVER_API_KEY`를 담을 Secret의 소유 주체와 이름. Chart는 `toolService.apiKeySecret`으로 이름만 참조한다
13. VM docker compose 배포(`.gitea/workflows/deploy.yaml`)를 계속 쓸지, 그 `deploy.sh`를 저장소로 가져올지.
현재 스크립트는 러너의 `/home/ubuntu/apps/prd-dap-gateway/`에 있어 이 저장소가 내용을 모른다
## 운영 적용 전 필수 보완

View File

@@ -108,8 +108,23 @@ SDK 검증은 `ToolExecutionService`가 Registry 기반 argument validation을
3. 실패하면 Tool Service를 호출하지 않고 기존 `JsonRpcException(INVALID_PARAMS)`으로 종료한다. SDK 원문 오류는
입력값을 포함할 수 있으므로 외부에는 `arguments do not match inputSchema`만 반환한다.
SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다. Registry가 제공하는
schema 자체의 허용 dialect와 `$ref` 원격 해석 정책은 운영 Registry 계약으로 별도 통제해야 한다.
SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다.
Registry가 제공하는 schema 자체의 허용 dialect와 `$ref` 해석 범위는
[ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)로 확정했다. `ToolSchemaReferencePolicy`
`ToolMetadata` 생성 시점에 문서 밖 `$ref`·`$dynamicRef`와 2020-12가 아닌 `$schema`를 거부하므로, 검증기가 schema에
적힌 주소로 조회를 시도할 수 있는 경로가 남지 않는다. `DefaultJsonSchemaValidator``SchemaRegistry`를 내부에서
생성해 정책 주입 지점을 열어 두지 않으므로, 이 통제는 SDK 밖에서만 걸 수 있다. `format` 키워드의 검증 강도는 아직
협의 항목이다.
`pattern` 정규식은 joni·graal-js를 해석하지 않아 `java.util.regex`로 검증된다. 백트래킹 폭증을 막기 위해
`ToolSchemaPatternPolicy`가 반복 구조·수량자 개수·정규식 길이를 제한하고 `maxLength` 동반 선언을 요구한다
([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)). 측정 근거와 남는 위험은 그 ADR에 있다.
`format`은 단언하지 않는다. 2020-12에서 format-assertion은 opt-in이고 SDK 검증기가 이를 켜지 않으므로,
`format: "date-time"`이나 `format: "ipv4"`에 임의 문자열을 넣어도 통과한다. 덕분에 입력 값을 정규식으로
컴파일하는 `format: "regex"` 경로도 실행되지 않는다. 이 동작은 `ToolArgumentValidatorTest`가 고정하므로,
SDK 업그레이드로 단언이 켜지면 테스트가 실패해 알 수 있다.
## 6. 의도적으로 도입하지 않은 SDK 기능

View File

@@ -0,0 +1,706 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:dd2dbf54-3ff5-57b2-bc28-765721359457",
"version": 1,
"metadata": {
"timestamp": "2026-08-18T00:00:00Z",
"component": {
"type": "application",
"bom-ref": "pkg:maven/io.shinhanlife.dap.biz.mcp/ax-hub-mcp-server@0.1.0",
"group": "io.shinhanlife.dap.biz.mcp",
"name": "ax-hub-mcp-server",
"version": "0.1.0",
"description": "AXHUB MCP&Tool Service 공통 스택 (Java 21 / Spring Boot 3.5.11)",
"purl": "pkg:maven/io.shinhanlife.dap.biz.mcp/ax-hub-mcp-server@0.1.0"
},
"properties": [
{
"name": "axhub:scope",
"value": "MCP Java SDK 2.0.0과 그 런타임 전이 의존, 그리고 빌드 환경. MCP Server와 Tool Service의 공통 스택에 적용된다"
},
{
"name": "axhub:source",
"value": "build.gradle + Gradle 로컬 캐시의 실제 pom/jar 판독"
}
]
},
"components": [
{
"type": "library",
"bom-ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0",
"name": "mcp-json-jackson2",
"version": "2.0.0",
"publisher": "Anthropic",
"description": "MCP JSON 직렬화 · JSON Schema 2020-12 검증 구현체",
"scope": "required",
"purl": "pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0",
"group": "io.modelcontextprotocol.sdk",
"hashes": [
{
"alg": "SHA-1",
"content": "2f9b7d72acb74d854589b7f22477aaaef4d84083"
},
{
"alg": "SHA-512",
"content": "58951bd4b1c5a385af5b146b5582bc457475e2933a220d2fd63c1aed4435d1fc6f585b068dc752170d58890bd4036947c82afa8686f872560c2d148d270654fe"
}
],
"licenses": [
{
"license": {
"id": "MIT",
"url": "https://opensource.org/licenses/MIT"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/modelcontextprotocol/java-sdk"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "직접 선언 (build.gradle implementation)"
},
{
"name": "axhub:note",
"value": "이 SBOM의 유일한 직접 선언 오픈소스"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
"name": "mcp-core",
"version": "2.0.0",
"publisher": "Anthropic",
"description": "MCP 표준 프로토콜 모델(McpSchema) · JSON-RPC 상수",
"scope": "required",
"purl": "pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
"group": "io.modelcontextprotocol.sdk",
"hashes": [
{
"alg": "SHA-1",
"content": "fd49feda3b9e6914a46a56ccd4a8f70e35156898"
},
{
"alg": "SHA-512",
"content": "44dcf26bddfaa4757d7b2d765cd48745a0130fbc074ebb59565a03f66c92f387073d109c54fe62e1a68f3df71629928df973f6adc8abe75d4f5cf76b1d1f6f0b"
}
],
"licenses": [
{
"license": {
"id": "MIT",
"url": "https://opensource.org/licenses/MIT"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/modelcontextprotocol/java-sdk"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← mcp-json-jackson2"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/com.networknt/json-schema-validator@2.0.0",
"name": "json-schema-validator",
"version": "2.0.0",
"publisher": "Network New Technologies Inc.",
"description": "JSON Schema draft 2020-12 검증 엔진 (Tool inputSchema 검증)",
"scope": "required",
"purl": "pkg:maven/com.networknt/json-schema-validator@2.0.0",
"group": "com.networknt",
"hashes": [
{
"alg": "SHA-1",
"content": "bc7c4ddf322d1295e3c296f28a9966590e6dea20"
},
{
"alg": "SHA-512",
"content": "bc033e50c66e72ad89df6442532b614fc984386aad2da68daaa098d81ac5a4a82933d0783d3f1a0ed5fe80e3bca6091d72acf5d7dcadcb50c3153100edcf334b"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/networknt/json-schema-validator"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← mcp-json-jackson2"
},
{
"name": "axhub:note",
"value": "optional인 joni·graal-js를 해석하지 않아 pattern 검증에 JDK 정규식 엔진을 사용한다"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/com.ethlo.time/itu@1.14.0",
"name": "itu",
"version": "1.14.0",
"publisher": "ethlo (Morten Haraldsen)",
"description": "RFC 3339 date/date-time 파싱 — json-schema-validator의 format 구현용",
"scope": "required",
"purl": "pkg:maven/com.ethlo.time/itu@1.14.0",
"group": "com.ethlo.time",
"hashes": [
{
"alg": "SHA-1",
"content": "c0f9f9d4f4404787e992ab3af5ae95f2fad79e47"
},
{
"alg": "SHA-512",
"content": "aa69a6af3a7123eb41425bbaf6834e16dc3323172709e2338b8a21b970fd21333d996515f42da4aa0225251e30542ad7d9c8332bdf7d62ed96b42fadc8a1520d"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/ethlo/itu"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← json-schema-validator"
},
{
"name": "axhub:note",
"value": "SDK 검증기가 format을 단언하지 않아 런타임에 호출되지 않는다. classpath에는 포함되므로 수록"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
"name": "jackson-dataformat-yaml",
"version": "2.19.4",
"publisher": "FasterXML, LLC",
"description": "YAML 형식 schema 로딩 (validator 부가 기능)",
"scope": "required",
"purl": "pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
"group": "com.fasterxml.jackson.dataformat",
"hashes": [
{
"alg": "SHA-1",
"content": "500956daea0869bf753b94fdaa77e5dc99847d79"
},
{
"alg": "SHA-512",
"content": "42cf2edacf2dea3c0616991a9a945c6e3e44dcb719918e76e6babae55601454397a1667bf75b7d55c74f96a7da7c0d9f60a0f4be60f84fd405fa31eb144f9b92"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/FasterXML/jackson-dataformats-text"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← json-schema-validator"
},
{
"name": "axhub:note",
"value": "Spring Boot 3.5.11 BOM이 2.19.4로 정렬"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/io.projectreactor/reactor-core@3.7.16",
"name": "reactor-core",
"version": "3.7.16",
"publisher": "VMware (Project Reactor)",
"description": "mcp-core가 참조하는 리액티브 타입 제공",
"scope": "required",
"purl": "pkg:maven/io.projectreactor/reactor-core@3.7.16",
"group": "io.projectreactor",
"hashes": [
{
"alg": "SHA-1",
"content": "dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be"
},
{
"alg": "SHA-512",
"content": "f0313eedd03acee06e7e38a915ecb8060d6996ffafbd05afeff4c7cdeb239e022b65f8f721290e228d5c30180d069a417cb40c3f782b643508fa0b64d11de10f"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/reactor/reactor-core"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← mcp-core"
},
{
"name": "axhub:note",
"value": "pom 요청 3.7.0 → reactor-bom 2024.0.15의 3.7.16"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/org.reactivestreams/reactive-streams@1.0.4",
"name": "reactive-streams",
"version": "1.0.4",
"publisher": "Reactive Streams SIG",
"description": "리액티브 스트림 표준 인터페이스",
"scope": "required",
"purl": "pkg:maven/org.reactivestreams/reactive-streams@1.0.4",
"group": "org.reactivestreams",
"hashes": [
{
"alg": "SHA-1",
"content": "3864a1320d97d7b045f729a326e1e077661f31b7"
},
{
"alg": "SHA-512",
"content": "cdab6bd156f39106cd6bbfd47df1f4b0a89dc4aa28c68c31ef12a463193c688897e415f01b8d7f0d487b0e6b5bd2f19044bf8605704b024f26d6aa1f4f9a2471"
}
],
"licenses": [
{
"license": {
"id": "MIT-0",
"url": "https://spdx.org/licenses/MIT-0.html"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "http://www.reactive-streams.org/"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← reactor-core"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
"name": "jackson-databind",
"version": "2.19.4",
"publisher": "FasterXML, LLC",
"description": "JSON 데이터 바인딩",
"scope": "required",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
"group": "com.fasterxml.jackson.core",
"hashes": [
{
"alg": "SHA-1",
"content": "7a39bf9257b726b90b80f27fa3f5174bc75162a5"
},
{
"alg": "SHA-512",
"content": "02a80c97ea12874f66802cb2c8909e5358639b41050bd04da495c0ee8db496a0d9d609a3c62a1dca7cbd89681bf340d6f6dbc507d4f21602aa1a7f31b2285ba8"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/FasterXML/jackson-databind"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← mcp-json-jackson2, json-schema-validator"
},
{
"name": "axhub:note",
"value": "pom 요청 2.20.1 / 2.18.3 → Spring Boot 3.5.11 BOM의 2.19.4로 정렬"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
"name": "jackson-core",
"version": "2.19.4",
"publisher": "FasterXML, LLC",
"description": "JSON 스트리밍 파서/생성기",
"scope": "required",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
"group": "com.fasterxml.jackson.core",
"hashes": [
{
"alg": "SHA-1",
"content": "a720ca9b800742699e041c3890f3731fe516085e"
},
{
"alg": "SHA-512",
"content": "987de559d452fb78557c038a02289454cf1354985bdb79df1087c5bc33db35c9510ee6c1c1dd3816e220a86a35d19820a8c32176a7d4fc4e5d3c7e65df5536d4"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/FasterXML/jackson-core"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← jackson-databind"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
"name": "jackson-annotations",
"version": "2.19.4",
"publisher": "FasterXML, LLC",
"description": "JSON 바인딩 애노테이션",
"scope": "required",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
"group": "com.fasterxml.jackson.core",
"hashes": [
{
"alg": "SHA-1",
"content": "bbb09b1e7f7f5108890270eb701cb3ddef991c05"
},
{
"alg": "SHA-512",
"content": "22a2ce8150c380b9dc00bfbdd026f26e626f483e8ceebfbb2087e9abd63462781daf4e18ca09543a7d0eb7b5c5625f02332d3251e29c2abc6016d69a7194a565"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/FasterXML/jackson-annotations"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← mcp-core, jackson-databind"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/org.slf4j/slf4j-api@2.0.17",
"name": "slf4j-api",
"version": "2.0.17",
"publisher": "QOS.ch",
"description": "로깅 파사드",
"scope": "required",
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.17",
"group": "org.slf4j",
"hashes": [
{
"alg": "SHA-1",
"content": "d9e58ac9c7779ba3bf8142aff6c830617a7fe60f"
},
{
"alg": "SHA-512",
"content": "9a3e79db6666a6096a3021bb2e1d918f30f589d8de51d6b600f8ebd92515a510ae2d8f87919cc2dfa8365d64f10194cac8dfa0fb950160eef0e9da06f6caaeb9"
}
],
"licenses": [
{
"license": {
"id": "MIT",
"url": "https://opensource.org/licenses/MIT"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://www.slf4j.org/"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← mcp-core, json-schema-validator"
},
{
"name": "axhub:note",
"value": "pom 요청 2.0.16 → Spring Boot 3.5.11 BOM의 2.0.17로 정렬"
}
]
},
{
"type": "library",
"bom-ref": "pkg:maven/org.yaml/snakeyaml@2.4",
"name": "snakeyaml",
"version": "2.4",
"publisher": "SnakeYAML",
"description": "YAML 파서 (jackson-dataformat-yaml 백엔드)",
"scope": "required",
"purl": "pkg:maven/org.yaml/snakeyaml@2.4",
"group": "org.yaml",
"hashes": [
{
"alg": "SHA-1",
"content": "e0666b825b796f85521f02360e77f4c92c5a7a07"
},
{
"alg": "SHA-512",
"content": "1573717e2c47868515cbed5265a6f77ebec23a0b5c6376ac18b9f5c2335beb65d4c68d2073d50143d59a60141980be8db1e493a85d7c78106cdb94a52e8361d2"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://bitbucket.org/snakeyaml/snakeyaml"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "전이 ← jackson-dataformat-yaml"
}
]
},
{
"type": "platform",
"bom-ref": "pkg:generic/jdk@21.0.5",
"name": "jdk",
"version": "21.0.5",
"publisher": "Eclipse Adoptium (Temurin)",
"description": "언어/실행 환경 — Java 21 toolchain",
"scope": "optional",
"purl": "pkg:generic/jdk@21.0.5",
"licenses": [
{
"expression": "GPL-2.0-only WITH Classpath-exception-2.0"
}
],
"externalReferences": [
{
"type": "website",
"url": "https://adoptium.net/temurin/releases/?version=21"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "build.gradle java.toolchain (vendor=ADOPTIUM)"
},
{
"name": "axhub:note",
"value": "표준가이드의 openjdk21u-jdk_x64_windows_hotspot_21.0.5 기준. Classpath Exception이 있어 이 JDK로 실행하는 애플리케이션에는 소스 공개 의무가 없다"
}
]
},
{
"type": "application",
"bom-ref": "pkg:generic/gradle@8.14.3",
"name": "gradle",
"version": "8.14.3",
"publisher": "Gradle Inc.",
"description": "빌드 도구 (gradle wrapper 고정)",
"scope": "optional",
"purl": "pkg:generic/gradle@8.14.3",
"hashes": [
{
"alg": "SHA-256",
"content": "bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531"
}
],
"licenses": [
{
"license": {
"id": "Apache-2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://gradle.org/"
}
],
"properties": [
{
"name": "axhub:dependencyPath",
"value": "gradle/wrapper/gradle-wrapper.properties"
},
{
"name": "axhub:note",
"value": "SHA-256은 wrapper의 distributionSha256Sum 값"
}
]
}
],
"dependencies": [
{
"ref": "pkg:maven/io.shinhanlife.dap.biz.mcp/ax-hub-mcp-server@0.1.0",
"dependsOn": [
"pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0"
]
},
{
"ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0",
"dependsOn": [
"pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
"pkg:maven/com.networknt/json-schema-validator@2.0.0",
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4"
]
},
{
"ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
"dependsOn": [
"pkg:maven/io.projectreactor/reactor-core@3.7.16",
"pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
"pkg:maven/org.slf4j/slf4j-api@2.0.17"
]
},
{
"ref": "pkg:maven/com.networknt/json-schema-validator@2.0.0",
"dependsOn": [
"pkg:maven/com.ethlo.time/itu@1.14.0",
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
"pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
"pkg:maven/org.slf4j/slf4j-api@2.0.17"
]
},
{
"ref": "pkg:maven/com.ethlo.time/itu@1.14.0",
"dependsOn": []
},
{
"ref": "pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
"dependsOn": [
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
"pkg:maven/org.yaml/snakeyaml@2.4"
]
},
{
"ref": "pkg:maven/io.projectreactor/reactor-core@3.7.16",
"dependsOn": [
"pkg:maven/org.reactivestreams/reactive-streams@1.0.4"
]
},
{
"ref": "pkg:maven/org.reactivestreams/reactive-streams@1.0.4",
"dependsOn": []
},
{
"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
"dependsOn": [
"pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
"pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4"
]
},
{
"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
"dependsOn": []
},
{
"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
"dependsOn": []
},
{
"ref": "pkg:maven/org.slf4j/slf4j-api@2.0.17",
"dependsOn": []
},
{
"ref": "pkg:maven/org.yaml/snakeyaml@2.4",
"dependsOn": []
},
{
"ref": "pkg:generic/jdk@21.0.5",
"dependsOn": []
},
{
"ref": "pkg:generic/gradle@8.14.3",
"dependsOn": []
}
]
}

74
docs/sbom/README.md Normal file
View File

@@ -0,0 +1,74 @@
# SBOM — AXHUB MCP&Tool Service
- 산출물: `AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json` (CycloneDX 1.5 정본), `AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx` (검토용)
- 대상: AXHUB MCP Server와 Tool Service의 공통 스택 (Java 21 / Spring Boot 3.5.11)
- 산출 기준 빌드: `ax-hub-mcp-server@0.1.0`
- 생성 기준일: 2026-08-18
## 대상 범위
MCP Server와 Tool Service는 같은 기술 스택과 같은 MCP SDK를 쓰므로 이 SBOM을 공통으로 적용한다.
`build.gradle`이 직접 선언한 오픈소스는 `io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0`
하나이며, 이 SBOM은 그 **런타임 전이 의존 전체**와 **빌드 환경**을 담는다. Spring Boot starter
계열(web / validation / data-redis / actuator)은 glow f/w가 제공하는 플랫폼 구성이라 범위 밖이다.
다만 목록은 **MCP Server 빌드(`ax-hub-mcp-server@0.1.0`) 하나에서 산출했다.** Tool Service가 이
스택 밖의 의존(예: DB 드라이버, 연계 라이브러리)을 추가하면 그만큼은 이 SBOM에 없으므로, 해당
빌드에서 다시 산출해 합쳐야 한다.
| 구분 | 개수 | 내용 |
|---|---|---|
| 런타임 의존성 (scope: required) | 12 | 실행 산출물 classpath에 올라가는 라이브러리 |
| 빌드 환경 (scope: optional) | 2 | JDK 21, Gradle 8.14.3 |
| 합계 | 14 | |
라이선스는 Apache-2.0 9건, MIT 3건, MIT-0 1건, GPL-2.0 with Classpath Exception 1건(JDK)이다.
라이브러리 12건은 모두 permissive이고, copyleft는 JDK 하나뿐이다. JDK는 Classpath Exception이
있어 이 JDK로 실행하는 애플리케이션에 소스 공개 의무가 생기지 않는다.
JDK 배포판은 표준가이드가 정한 Eclipse Temurin
(`openjdk21u-jdk_x64_windows_hotspot_21.0.5`)이며, `build.gradle`의 toolchain에
`vendor = JvmVendorSpec.ADOPTIUM`으로 고정해 다른 배포판으로 빌드되지 않게 했다. 실행 컨테이너도
같은 계열인 `eclipse-temurin:21-jre`를 쓴다.
## 제외 항목
제외 항목과 사유는 엑셀 `Exclusions` 시트가 정본이다. 요약하면 다음과 같다.
- **test scope와 annotationProcessor** — 선언 4건. 산출물에 포함되지 않는다.
- `spring-boot-starter-test`, `com.squareup.okhttp3:mockwebserver:4.12.0`,
`org.junit.platform:junit-platform-launcher` (test scope)
- `org.springframework.boot:spring-boot-configuration-processor` (annotationProcessor)
- `joni`, `graal-js`, `graal-sdk` — json-schema-validator의 `optional`. ECMA262 정규식 검증을
쓰지 않아 해석되지 않으므로 약 50MB가 빠진다.
- `jakarta.servlet-api:6.1.0` — mcp-core의 `provided`. 산출물에 포함되지 않고 서블릿 컨테이너가 제공한다.
- `mcp:2.0.0`(aggregate), `mcp-json-jackson3:2.0.0` — Jackson 3 경로를 쓰지 않아 선언하지 않는다.
자세한 배경은 [mcp-java-sdk-adoption.md](../mcp-java-sdk-adoption.md) 참고.
## 산출 방법과 한계
버전과 해시는 `build.gradle` 선언에서 출발해 Gradle 로컬 캐시의 실제 `pom`을 따라가 그래프를
만들고, 캐시된 실제 jar 바이너리에서 SHA-512 / SHA-1을 직접 계산했다. Gradle 배포본의 SHA-256은
wrapper의 `distributionSha256Sum` 값을 그대로 옮겼다.
`gradlew dependencies`로 해석 결과를 대조하려 했으나 sandbox에서 gradle daemon이 뜨지 않아
(`Unable to establish loopback connection`) 실행하지 못했다. 따라서 다음 버전 정렬은 pom과
Spring Boot BOM 판독에 근거한 것이며, 빌드 환경에서 한 번 확인해야 한다.
```bash
./gradlew dependencies --configuration runtimeClasspath
```
| 컴포넌트 | pom 요청 버전 | 수록 버전 | 근거 |
|---|---|---|---|
| jackson-databind | 2.20.1 (mcp-json-jackson2) | 2.19.4 | Spring Boot 3.5.11 → jackson-bom 2.19.4 |
| jackson-databind | 2.18.3 (json-schema-validator) | 2.19.4 | 위와 동일 |
| reactor-core | 3.7.0 (mcp-core) | 3.7.16 | Spring Boot 3.5.11 → reactor-bom 2024.0.15 |
| slf4j-api | 2.0.16 (mcp-core) | 2.0.17 | Spring Boot 3.5.11 관리 버전 |
가장 확인이 필요한 항목은 jackson-databind다. SDK가 요청한 2.20.1이 `io.spring.dependency-management`
의해 2.19.4로 내려가므로, initialize / tools/list / tools/call 직렬화 계약 테스트로 동작을 확인한다.
CycloneDX 1.5 공식 JSON Schema 원본 대조는 폐쇄망이라 수행하지 않았다. 대신 생성 시점에
`dependencies`의 모든 `ref` / `dependsOn`이 실재하는 `bom-ref`를 가리키는지, 컴포넌트가 빠짐없이
`dependencies`에 등장하는지 구조 점검을 통과시켰다.

View File

@@ -41,7 +41,7 @@ public record McpProperties(
*/
public McpProperties {
bundles = bundles == null ? List.of() : List.copyOf(bundles);
portal = portal == null ? new Portal(false, "", "", 300) : portal;
portal = portal == null ? new Portal(false, "", 300) : portal;
}
/**
@@ -165,7 +165,7 @@ public record McpProperties(
* 포털이 소유한 Tool Service registry 조회 설정입니다.
* MCP 요청을 직접 처리하지 않고 배경 refresh가 route별 Tool Service 위치와 revision을 읽을 때 사용합니다.
*/
public record Portal(boolean enabled, String routeKey, String registryUrl, @Min(1) long refreshIntervalSeconds) {
public record Portal(boolean enabled, String registryUrl, @Min(1) long refreshIntervalSeconds) {
}
/**

View File

@@ -2,6 +2,8 @@ package io.shinhanlife.dap.biz.mcp.observability;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryRefreshScheduler;
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
import java.util.List;
import java.util.Set;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@@ -28,16 +30,28 @@ public class ToolCatalogHealthIndicator implements HealthIndicator {
/**
* 기동 preload 시도가 끝났고 usable snapshot이 있을 때만 UP을 반환합니다. 조회 상태 외에 Tool 이름이나 개수 같은 카탈로그 내용은 노출하지 않습니다.
*
* <p>한 배포가 여러 route를 서비스하는 구성에서는 <b>route 하나만 준비돼도 UP</b>입니다.
* readiness는 Pod 전체의 트래픽 게이트여서 route별 상태를 표현할 수 없고, 모든 route를 요구하면
* Tool Service 하나의 장애가 정상 route까지 트래픽에서 제외해 장애 범위를 오히려 넓히기 때문입니다.
* 대신 준비되지 않은 route를 detail로 노출해 관제가 부분 상태를 감지하게 합니다.
*/
@Override
public Health health() {
boolean firstAttemptCompleted = scheduler.firstAttemptCompleted();
boolean usableSnapshot = registryService.hasUsableSnapshot();
Set<String> readyRoutes = registryService.readyRouteKeys();
List<String> pendingRoutes = registryService.knownRouteKeys().stream()
.filter(routeKey -> !readyRoutes.contains(routeKey))
.sorted()
.toList();
Health.Builder health = firstAttemptCompleted && usableSnapshot ? Health.up() : Health.down();
return health.withDetail(
"firstDiscoveryAttempt",
firstAttemptCompleted ? "completed" : "pending")
.withDetail("usableSnapshot", usableSnapshot)
.withDetail("readyRoutes", readyRoutes.stream().sorted().toList())
.withDetail("routesWithoutSnapshot", pendingRoutes)
.build();
}
}

View File

@@ -74,15 +74,39 @@ public class PortalToolRegistryClient implements ToolRegistryClient {
/**
* 포털 전체 registry snapshot API를 한 번 호출해 route별 Tool catalog를 구성합니다.
* 응답의 {@code routes[]}에 있는 각 route마다 Tool Service manifest를 조회해 route별 in-memory snapshot 후보를 만듭니다.
*
* <p>한 route의 조회 실패는 그 route만 결과에서 빠뜨리고 나머지 route의 조회를 계속합니다.
* "aggregate는 전부 아니면 전무"는 카탈로그 <b>하나</b>를 온전하게 유지하기 위한 규칙이므로 route 안에서만 적용해야 하며,
* 여기서 예외를 그대로 올리면 Tool Service 하나의 장애가 전 route의 갱신을 멈춰 서로 다른 업무가 서로를 막습니다.
* 빠진 route의 기존 snapshot을 지울지는 호출자가 {@link #knownRoutes()}로 판단합니다.
*/
@Override
public Map<String, List<ToolMetadata>> fetchAllTools() {
ensurePortalRegistryLoaded();
Map<String, List<ToolMetadata>> snapshots = new LinkedHashMap<>();
bundlesByRoute.forEach((routeKey, bundles) -> snapshots.put(routeKey, fetchRouteTools(routeKey, bundles)));
bundlesByRoute.forEach((routeKey, bundles) -> {
try {
snapshots.put(routeKey, fetchRouteTools(routeKey, bundles));
} catch (RuntimeException exception) {
log.warn(
"Portal route catalog refresh failed; other routes continue. routeKey={} reason={} message={}",
routeKey,
exception.getClass().getSimpleName(),
exception.getMessage());
}
});
return Map.copyOf(snapshots);
}
/**
* 포털 registry가 선언한 route key 전체를 반환합니다.
* 조회 성공 여부와 무관하며, 포털 응답에서 사라진 route만 이 집합에서 빠집니다.
*/
@Override
public Set<String> knownRoutes() {
return Set.copyOf(bundlesByRoute.keySet());
}
/**
* 포털 registry API를 호출해 route별 Tool Server endpoint 목록만 memory에 갱신합니다.
* manifest 조회는 수행하지 않으며, 실패하면 기존 endpoint 목록이나 Redis fallback 규칙을 호출자에게 전달합니다.

View File

@@ -5,7 +5,8 @@ import com.fasterxml.jackson.databind.JsonNode;
/**
* 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고,
* {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다.
* {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. 생성 시점에 {@link ToolSchemaReferencePolicy}와 {@link ToolSchemaPatternPolicy}로
* {@code inputSchema}를 검사하므로, 어느 조회 경로로 들어온 metadata든 문서 밖을 가리키는 참조나 되돌아오는 데 오래 걸리는 정규식을 담은 채로는 만들어지지 않습니다.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ToolMetadata(
@@ -19,6 +20,18 @@ public record ToolMetadata(
JsonNode publicDefinition,
boolean exactEndpoint) {
/**
* 모든 생성 경로가 지나는 표준 생성자로, {@code inputSchema}가 문서 밖을 참조하지 않는지와 정규식이 빨리 끝나는지 확인합니다. Portal 매니페스트 파싱, local 파일 로딩, Redis snapshot
* 역직렬화가 모두 여기를 지나므로 검사 지점이 하나로 모입니다. 위반 시 {@link IllegalStateException}을 던져 해당 Tool이 Registry에 올라가지 못하게 합니다.
*/
public ToolMetadata {
ToolSchemaReferencePolicy.assertNoExternalReference(inputSchema);
ToolSchemaPatternPolicy.assertPatternsTerminateQuickly(inputSchema);
}
/**
* {@code exactEndpoint}를 쓰지 않는 호출자를 위해 기본값 {@code false}로 표준 생성자에 위임합니다.
*/
public ToolMetadata(
String name,
String version,

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dap.biz.mcp.registry;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Tool metadata의 원천(source)을 읽는 역할입니다.
@@ -35,6 +36,16 @@ public interface ToolRegistryClient {
return false;
}
/**
* 원천이 현재 알고 있는 route key 전체를 반환합니다.
* {@link #fetchAllTools()}가 조회에 <b>성공한</b> route만 담는 것과 달리, 이 집합은 조회 성공 여부와 무관하게 원천이 선언한 route를 뜻합니다.
* 호출자는 이 둘의 차이로 "이번에 조회가 실패한 route"와 "원천에서 사라진 route"를 구분하며, 전자의 기존 snapshot을 지우지 않습니다.
* route 개념이 없는 구현은 빈 집합을 반환하고, 호출자는 기존 제거 규칙을 그대로 사용합니다.
*/
default Set<String> knownRoutes() {
return Set.of();
}
/**
* route 구분이 없는 기존 호출 경로를 위해 기본 route의 Tool 목록을 읽습니다.
*/

View File

@@ -28,13 +28,14 @@ public class ToolRegistryRefreshScheduler {
}
/**
* 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다. 먼저 다른 replica가 공유 cache에 남긴 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄이고, 이어서 원천을 조회해 최신
* 상태로 교체합니다. 두 단계 모두 실패해도 애플리케이션은 계속 기동합니다.
* 애플리케이션 준비 직후 jitter 없이 첫 Tool snapshot을 best-effort 방식으로 미리 적재합니다.
* 먼저 원천 registry에서 route 목록을 확보하고, 다른 replica가 공유 cache에 남긴 route별 snapshot으로 warm start해 기동 직후의 빈 목록 구간을 줄인 뒤, 원천을 조회해 최신 상태로 교체합니다.
* route 목록을 모르면 어느 Redis key를 읽어야 할지 알 수 없으므로 registry 조회가 warm start보다 먼저 와야 하며, 세 단계가 모두 실패해도 애플리케이션은 계속 기동합니다.
*/
@EventListener(ApplicationReadyEvent.class)
public void preload() {
safeWarmStart();
safePortalRefresh("preload");
safeWarmStart();
safeManifestRefresh("preload");
firstAttemptCompleted = true;
}

View File

@@ -9,6 +9,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
@@ -20,7 +21,8 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
/**
* Tool Registry metadata 議고쉶???⑥씪 吏꾩엯?먯씠硫??붿껌 寃쎈줈?€ 諛곌꼍 媛깆떊 寃쎈줈瑜?遺꾨━?섎뒗 ?쒕퉬?ㅼ엯?덈떎. {@code tools/list}?€ {@code tools/call}???붿껌 寃쎈줈??in-memory snapshot留??쎌쑝誘€濡?Redis ?μ븷??吏€?곗씠 ?묐떟?? * ?곹뼢??二쇱? ?딆뒿?덈떎. Redis??諛곌꼍 媛깆떊怨?warm start?먯꽌留??ъ슜?섎뒗 replica 媛?怨듭쑀 吏€?먯씠硫? ?먯쿇 議고쉶 ?깃났 寃곌낵留??€?ν빀?덈떎. 二쇱슂 ?섏〈?깆? ?먯쿇 port {@link ToolRegistryClient}?€ ?좏깮??Redis cache?낅땲??
* Tool Registry metadata 조회의 단일 진입점이며 요청 경로와 배경 갱신 경로를 분리하는 서비스입니다. {@code tools/list} {@code tools/call}의 요청 경로는 in-memory snapshot만 읽으므로 Redis 장애나 지연이 응답에
* 영향을 주지 않습니다. Redis는 배경 갱신과 warm start에서만 쓰는 replica 간 공유 지점이며 원천 조회에 성공한 결과만 저장합니다. 주요 의존성은 원천 port {@link ToolRegistryClient}와 선택적 Redis cache입니다.
*/
@Service
public class ToolRegistryService {
@@ -36,7 +38,7 @@ public class ToolRegistryService {
new ConcurrentHashMap<>();
/**
* ?먯쿇 Registry?€ memory쨌?좏깮??Redis 怨듭쑀 cache瑜?二쇱엯諛쏆뒿?덈떎.
* 원천 Registry memory·선택적 Redis 공유 cache를 주입받습니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient, Optional<RedisToolRegistryCache> redisCache) {
@@ -45,8 +47,8 @@ public class ToolRegistryService {
}
/**
* ?먯쿇 Registry, ?좏깮??Redis 怨듭쑀 cache, Tool 紐⑸줉 蹂€寃??대깽??諛쒗뻾?먮? 二쇱엯諛쏆뒿?덈떎.
* Spring 湲곕룞 ???몄텧?섎ʼn, refresh ?깃났?쇰줈 湲곗〈 route snapshot???щ씪吏??뚮쭔 ?대깽?몃? 諛쒗뻾?⑸땲??
* 원천 Registry, 선택적 Redis 공유 cache, Tool 목록 변경 이벤트 발행자를 주입받습니다.
* Spring 기동 시 호출되며, refresh 성공으로 기존 route snapshot이 달라졌을 때만 이벤트를 발행합니다.
*/
public ToolRegistryService(
ToolRegistryClient registryClient,
@@ -56,8 +58,8 @@ public class ToolRegistryService {
}
/**
* ?먯쿇 Registry, ?좏깮??Redis cache, 蹂€寃??대깽??諛쒗뻾?? JSON 吏곷젹???꾧뎄瑜?二쇱엯諛쏆뒿?덈떎.
* Spring 湲곕룞 ???몄텧?섎ʼn snapshot 蹂€寃?寃€利?濡쒓렇瑜?JSON ?뺥깭濡??④만 ???덇쾶 ObjectMapper瑜?蹂닿??⑸땲??
* 원천 Registry, 선택적 Redis cache, 변경 이벤트 발행자, JSON 직렬화 도구를 주입받습니다.
* Spring 기동 시 호출되며 snapshot 변경 검증 로그를 JSON 형태로 남길 수 있게 ObjectMapper를 보관합니다.
*/
@Autowired
public ToolRegistryService(
@@ -72,15 +74,16 @@ public class ToolRegistryService {
}
/**
* ?쒖꽦 Tool 紐⑸줉??in-memory snapshot?먯꽌 ?쎌뒿?덈떎. ?붿껌 寃쎈줈?먯꽌??Redis瑜??몄텧?섏? ?딆쑝誘€濡?Redis ?μ븷??吏€?곗씠 {@code tools/list} ?묐떟 ?쒓컙???곹뼢??二쇱? ?딆뒿?덈떎. snapshot???꾩쭅 鍮꾩뼱 ?덈뒗 湲곕룞 吏곹썑?먮쭔 ?먯쿇????踰? * 議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
* 활성 Tool 목록을 in-memory snapshot에서 읽습니다. 요청 경로에서는 Redis를 호출하지 않으므로 Redis 장애나 지연이 {@code tools/list} 응답 시간에 영향을 주지 않습니다. snapshot이 아직 비어 있는 기동 직후에만 원천을 한 번
* 조회해 cold start 공백을 메웁니다.
*/
public List<ToolMetadata> listTools() {
return listTools("");
}
/**
* route蹂?in-memory snapshot?먯꽌 ?쒖꽦 Tool 紐⑸줉???쎌뒿?덈떎.
* ?붿껌 route??snapshot???놁쑝硫??대떦 route??Registry ?먯쿇????踰?議고쉶??cold start 怨듬갚??硫붿썎?덈떎.
* routein-memory snapshot에서 활성 Tool 목록을 읽습니다.
* 요청 routesnapshot이 없으면 해당 routeRegistry 원천에서 한 번 조회해 cold start 공백을 메웁니다.
*/
public List<ToolMetadata> listTools(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -92,34 +95,54 @@ public class ToolRegistryService {
}
/**
* ?붿껌??泥섎━?????덈뒗 Tool snapshot??memory???곸옱?먮뒗吏€ 諛섑솚?⑸땲?? ?먯쿇 ?먮뒗 Redis?먯꽌 ?깃났?곸쑝濡?梨꾪깮??鍮?紐⑸줉???좏슚???꾩껜 ?곹깭?대?濡?{@code null} ?щ?留??먮떒?섎ʼn, readiness ?뺤씤 怨쇱젙?먯꽌 Redis??Tool Service瑜? * ?몄텧?섏? ?딆뒿?덈떎.
* 요청을 처리할 수 있는 Tool snapshotmemory에 존재하는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 값이 목록에 유효한 전체 상태이므로 {@code null} 여부만으로 판단하며, readiness 확인 과정에서 RedisTool Service
* 호출하지 않습니다.
*/
public boolean hasUsableSnapshot() {
return !snapshotsByRoute.isEmpty();
}
/**
* 湲곕룞 吏곹썑 ?ㅻⅨ replica媛€ 怨듭쑀 吏€?먯뿉 ?€?ν빐 ??snapshot??癒쇱? ?곸옱?⑸땲?? 泥??먯쿇 議고쉶媛€ ?앸굹湲??꾩쓽 鍮?紐⑸줉 援ш컙??以꾩씠湲??꾪븳 best-effort ?숈옉?대ʼn, ?ㅽ뙣?섍굅??媛믪씠 ?놁쑝硫??꾨Т寃껊룄 ?섏? ?딆뒿?덈떎.
* 현재 in-memory snapshot을 확보한 route key 집합을 반환합니다.
* 한 배포가 여러 route를 서비스하는 구성에서는 일부 route만 준비된 상태가 정상적으로 발생하므로, readiness 판정이 아니라 그 부분 상태를 관측하는 데 사용합니다.
*/
public Set<String> readyRouteKeys() {
return Set.copyOf(snapshotsByRoute.keySet());
}
/**
* 원천이 선언한 route key 집합을 그대로 전달합니다.
* {@link #readyRouteKeys()}와의 차이가 곧 "원천은 알고 있으나 아직 Tool 목록을 확보하지 못한 route"이며, 관제는 이 차이로 부분 장애를 감지합니다.
*/
public Set<String> knownRouteKeys() {
return registryClient.knownRoutes();
}
/**
* 기동 직후 다른 replica가 공유 지점에 저장해 둔 snapshot을 먼저 적재합니다. 첫 원천 조회가 끝나기 전의 빈 목록 구간을 줄이기 위한 best-effort 동작이며, 실패하거나 값이 없으면 아무것도 하지 않습니다.
* 어느 Redis key를 읽을지는 원천이 선언한 route 목록이 정하므로, route를 모르는 시점에 호출하면 기존 단일 route key만 시도합니다.
*/
public void warmStartFromSharedCache() {
if (!snapshotsByRoute.isEmpty()) {
return;
}
redisCache
.flatMap(cache -> cache.loadSnapshot(""))
.ifPresent(tools -> snapshotsByRoute.putIfAbsent("", List.copyOf(tools)));
Set<String> declaredRoutes = registryClient.knownRoutes();
Set<String> routeKeys = declaredRoutes.isEmpty() ? Set.of("") : declaredRoutes;
routeKeys.forEach(routeKey -> redisCache
.flatMap(cache -> cache.loadSnapshot(routeKey))
.ifPresent(tools -> snapshotsByRoute.putIfAbsent(routeKey, List.copyOf(tools))));
}
/**
* ?쒖? Tool ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎. cache媛€ ?ㅻ옒?먯쓣 ???덉쑝誘€濡?泥?議고쉶?먯꽌 紐?李얠쑝硫?Registry瑜???踰?refresh????理쒖쥌 ?먮떒?⑸땲??
* 표준 Tool 이름과 일치하는 활성 Tool 하나를 찾습니다. cache가 오래됐을 수 있으므로 첫 조회에서 못 찾으면 Registry를 한 번 refresh한 뒤 최종 판단합니다.
*/
public ToolMetadata findEnabledTool(String name) {
return findEnabledTool("", name);
}
/**
* ?붿껌 route??Tool snapshot?먯꽌 ?대쫫???쇱튂?섎뒗 ?쒖꽦 Tool ?섎굹瑜?李얠뒿?덈떎.
* route蹂?cache媛€ ?ㅻ옒?섏뿀?????덉쑝誘€濡?理쒖큹 miss ???대떦 route留?refresh????理쒖쥌 ?먮떒?⑸땲??
* 요청 routeTool snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다.
* routecache가 오래됐을 수 있으므로 최초 miss 시 해당 routerefresh한 뒤 최종 판단합니다.
*/
public ToolMetadata findEnabledTool(String routeKey, String name) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -143,15 +166,16 @@ public class ToolRegistryService {
}
/**
* Registry ?먯쿇??吏곸젒 ?쎌뼱 ?쒖꽦 Tool snapshot??媛깆떊?⑸땲?? 議고쉶???깃났?덉쓣 ?뚮쭔 snapshot??援먯껜?섍퀬 怨듭쑀 cache???€?ν븯誘€濡? ?ㅽ뙣媛€ 湲곗〈 紐⑸줉??鍮꾩슦嫄곕굹 ?ㅻⅨ replica媛€ ?€?ν븳 ?뺤긽 snapshot????뼱?곗? ?딆뒿?덈떎. memory瑜? * 癒쇱? 媛깆떊??Redis ?μ븷?€ 臾닿??섍쾶 理쒖떊 ?곹깭瑜??좎??⑸땲?? ?먯쿇 議고쉶媛€ ?ㅽ뙣?섎㈃ 湲곗〈 memory瑜??좎??섍퀬, memory媛€ 鍮꾩뼱 ?덉쓣 ?뚮쭔 怨듭쑀 cache瑜?梨꾪깮?⑸땲??
* Registry 원천을 직접 읽어 활성 Tool snapshot을 갱신합니다. 조회에 성공했을 때만 snapshot을 교체하고 공유 cache에 저장하므로, 실패가 기존 목록을 비우거나 다른 replica가 저장한 정상 snapshot을 덮어쓰지 않습니다. memory를
* 먼저 갱신해 Redis 장애와 무관하게 최신 상태를 유지합니다. 원천 조회가 실패하면 기존 memory를 유지하고, memory가 비어 있을 때만 공유 cache를 채택합니다.
*/
public List<ToolMetadata> refresh() {
return refresh("");
}
/**
* 吏€?뺥븳 route??Registry ?먯쿇??吏곸젒 ?쎌뼱 route蹂?snapshot??媛깆떊?⑸땲??
* 媛숈? route???숈떆 refresh??single-flight濡?臾띔퀬, ?ㅻⅨ route???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲??
* 지정한 routeRegistry 원천을 직접 읽어 routesnapshot을 갱신합니다.
* 같은 route의 동시 refreshsingle-flight로 묶고, 다른 route는 서로 독립적으로 갱신합니다.
*/
public List<ToolMetadata> refresh(String routeKey) {
String normalizedRouteKey = normalizeRouteKey(routeKey);
@@ -174,11 +198,19 @@ public class ToolRegistryService {
}
/**
* ?꾩옱 memory???뚮젮吏?紐⑤뱺 route瑜?二쇨린?곸쑝濡?媛깆떊?⑸땲??
* ?꾩쭅 route ?붿껌???놁쑝硫?湲곗〈 湲곕낯 route留?媛깆떊??湲곗〈 ?⑥씪 route ?숈옉???좎??⑸땲??
* 원천이 알고 있는 모든 route를 주기적으로 갱신합니다.
* 원천이 route 목록을 제공하면 제거 판단을 그 목록으로만 하고, route 개념이 없는 원천에서는 기존 기본 route만 갱신해 기존 단일 route 동작을 유지합니다.
*/
public void refreshKnownRoutes() {
Map<String, List<ToolMetadata>> snapshots = registryClient.fetchAllTools();
Set<String> knownRoutes = registryClient.knownRoutes();
if (!knownRoutes.isEmpty()) {
// 원천이 route 목록을 스스로 알고 있으면 제거 판단은 그 목록만 따른다.
// 조회 결과를 기준으로 지우면 이번 주기에 실패한 route의 정상 snapshot까지 사라진다.
snapshotsByRoute.keySet().removeIf(routeKey -> !knownRoutes.contains(routeKey));
snapshots.forEach(this::replaceSnapshot);
return;
}
if (!snapshots.isEmpty()) {
snapshotsByRoute.keySet().removeIf(routeKey -> !snapshots.containsKey(routeKey));
snapshots.forEach(this::replaceSnapshot);
@@ -191,15 +223,15 @@ public class ToolRegistryService {
}
/**
* ?ы꽭泥섎읆 蹂꾨룄 registry瑜?媛€吏??먯쿇??endpoint 紐⑸줉留?媛깆떊?⑸땲??
* Tool manifest 議고쉶?€ memory snapshot 援먯껜???섑뻾?섏? ?딆쑝硫? scheduler媛€ ?ы꽭 ?꾩슜 二쇨린?먯꽌 ?몄텧?⑸땲??
* 포털처럼 별도 registry를 가진 원천의 endpoint 목록만 갱신합니다.
* Tool manifest 조회와 memory snapshot 교체는 수행하지 않으며, scheduler가 포털 전용 주기에서 호출합니다.
*/
public boolean refreshSourceRegistry() {
return registryClient.refreshSourceRegistry();
}
/**
* Tool ?먯쿇????踰?議고쉶?섍퀬 ?깃났???꾩껜 snapshot留?memory?€ Redis??諛섏쁺?⑸땲?? ?먯쿇 ?ㅽ뙣 ??湲곗〈 memory瑜?理쒖슦?좎쑝濡??좎??섍퀬, memory媛€ 鍮꾩뼱 ?덉쓣 ?뚮쭔 Redis last-good??梨꾪깮?⑸땲??
* Tool 원천을 한 번 조회하고 성공한 전체 snapshotmemory Redis에 반영합니다. 원천 실패 시 기존 memory를 최우선으로 유지하고, memory가 비어 있을 때만 Redis last-good을 채택합니다.
*/
private List<ToolMetadata> refreshOnce(String routeKey) {
try {
@@ -227,11 +259,8 @@ public class ToolRegistryService {
}
/**
* ?ㅻⅨ ?몄텧???쒖옉??refresh 寃곌낵瑜?湲곕떎由щʼn ?먮옒 RuntimeException ?좏삎??蹂댁〈?⑸땲?? ?щ윭 cache miss媛€ ?숈떆??諛쒖깮?대룄 紐⑤뱺 ?몄텧?먭? 媛숈? source fetch 寃곌낵瑜??ъ슜?⑸땲??
*/
/**
* ?꾩껜 registry snapshot 議고쉶 寃곌낵瑜?route蹂?memory snapshot??諛섏쁺?⑸땲??
* ?먯쿇 議고쉶媛€ ?대? ?깃났??紐⑸줉留??ㅼ뼱?ㅻ?濡??붿껌 寃쎈줈?€ Redis 寃쎈줈瑜?嫄대뱶由ъ? ?딄퀬, 湲곗〈 snapshot怨?鍮꾧탳??濡쒓렇?€ 蹂€寃??대깽?몃쭔 泥섎━?⑸땲??
* 전체 registry snapshot 조회 결과를 route별 memory snapshot에 반영합니다.
* 원천 조회가 이미 성공한 목록만 들어오므로 요청 경로와 Redis 경로를 건드리지 않고, 기존 snapshot과 비교한 로그와 변경 이벤트만 처리합니다.
*/
private void replaceSnapshot(String routeKey, List<ToolMetadata> tools) {
List<ToolMetadata> immutableTools = List.copyOf(tools);
@@ -241,6 +270,11 @@ public class ToolRegistryService {
redisCache.ifPresent(cache -> cache.saveSnapshot(routeKey, immutableTools));
}
/**
* 다른 호출이 이미 시작한 refresh의 결과를 기다리며 원래 RuntimeException 유형을 그대로 보존합니다.
* 여러 cache miss가 동시에 발생해도 모든 호출자가 같은 원천 조회 한 번의 결과를 공유하도록 합니다.
* {@link CompletionException}으로 감싸인 원인을 풀어 상위 JSON-RPC 오류 변환이 원래 예외를 보게 합니다.
*/
private List<ToolMetadata> awaitRefresh(CompletableFuture<List<ToolMetadata>> refresh) {
try {
return refresh.join();
@@ -253,7 +287,7 @@ public class ToolRegistryService {
}
/**
* ?대쫫 議곌굔?쇰줈 ?쒖꽦 Tool ?꾨낫瑜?李얠뒿?덈떎. ?대쫫 以묐났?€ ?먯쿇 snapshot 蹂묓빀 ?④퀎?먯꽌 嫄곕??⑸땲??
* 이름 조건으로 활성 Tool 후보를 찾습니다. 이름 중복은 원천 snapshot 병합 단계에서 거부합니다.
*/
private Optional<ToolMetadata> match(List<ToolMetadata> tools, String name) {
return tools.stream()
@@ -263,7 +297,7 @@ public class ToolRegistryService {
}
/**
* 李얠? 紐삵븳 Tool ?대쫫???ы븿??Tool not found ?덉쇅瑜?留뚮벊?덈떎.
* 찾지 못한 Tool 이름을 포함한 Tool not found 예외를 만듭니다.
*/
private JsonRpcException notFound(String name) {
return new JsonRpcException(
@@ -271,8 +305,8 @@ public class ToolRegistryService {
}
/**
* 湲곗〈 snapshot??議댁옱?섍퀬 ??snapshot怨??ㅻ? ?뚮쭔 Tool 紐⑸줉 蹂€寃??대깽?몃? 諛쒗뻾?⑸땲??
* 理쒖큹 濡쒕뵫?€ Agent Builder媛€ ?꾩쭅 紐⑸줉??諛쏄린 ?꾩씪 ???덉쑝誘€濡??뚮┝ ?€?곸뿉???쒖쇅?섍퀬, ?ㅼ젣 援먯껜媛€ 諛쒖깮??refresh?먮쭔 ?곹뼢??以띾땲??
* 기존 snapshot이 존재하고 새 snapshot과 다를 때만 Tool 목록 변경 이벤트를 발행합니다.
* 최초 로딩은 Agent Builder가 아직 목록을 받기 전일 수 있으므로 알림 대상에서 제외하고, 실제 교체가 발생한 refresh에만 영향을 줍니다.
*/
private void publishListChangedIfNeeded(
String routeKey, List<ToolMetadata> previous, List<ToolMetadata> current) {
@@ -282,8 +316,8 @@ public class ToolRegistryService {
}
/**
* 濡쒖뺄 寃€利앹쓣 ?꾪빐 route蹂?in-memory snapshot??理쒖큹 ?깅줉?섍굅???ㅼ젣 蹂€寃쎈맆 ?뚮쭔 INFO 濡쒓렇濡??④퉩?덈떎.
* Portal ?먮뒗 Tool Service revision 蹂€寃쎌씠 memory??諛섏쁺?섏뿀?붿? ?뺤씤?????덈룄濡?Tool metadata ?꾩껜瑜?湲곕줉?⑸땲??
* 로컬 검증을 위해 routein-memory snapshot이 최초 등록되거나 실제 변경될 때만 INFO 로그로 남깁니다.
* Portal 또는 Tool Service revision 변경이 memory에 반영되었는지 확인할 수 있도록 Tool metadata 전체를 기록합니다.
*/
private void logSnapshot(String routeKey, List<ToolMetadata> previous, List<ToolMetadata> current) {
boolean changed = previous == null || !previous.equals(current);
@@ -296,8 +330,8 @@ public class ToolRegistryService {
}
/**
* 寃€利?濡쒓렇???ъ슜??route蹂?snapshot ?댁슜??JSON 臾몄옄?대줈 蹂€?섑빀?덈떎.
* 吏곷젹???ㅽ뙣媛€ refresh ?깃났 ?щ????곹뼢??二쇱? ?딅룄濡??ㅽ뙣 ??理쒖냼 臾몄옄???쒗쁽?쇰줈 ?€泥댄빀?덈떎.
* 검증 로그에 사용할 routesnapshot 내용을 JSON 문자열로 변환합니다.
* 직렬화 실패가 refresh 성공 여부에 영향을 주지 않도록 실패 시 최소 문자열 표현으로 대체합니다.
*/
private String snapshotJson(String routeKey, List<ToolMetadata> current) {
Map<String, Object> body = new LinkedHashMap<>();
@@ -313,7 +347,7 @@ public class ToolRegistryService {
}
/**
* route key??null怨?怨듬갚??湲곗〈 ?⑥씪 snapshot key??鍮?臾몄옄?대줈 ?뺢퇋?뷀빀?덈떎.
* route keynull과 공백을 기존 단일 snapshot key인 빈 문자열로 정규화합니다.
*/
private String normalizeRouteKey(String routeKey) {
return routeKey == null ? "" : routeKey.trim();

View File

@@ -0,0 +1,232 @@
package io.shinhanlife.dap.biz.mcp.registry;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/**
* Tool의 {@code inputSchema}가 요청 스레드를 오래 붙잡는 정규식 검증을 유발하지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 매니페스트·local 파일·Redis snapshot 중
* 어느 경로로 들어온 schema든 같은 규칙을 통과하며, 요청 경로에는 비용을 더하지 않습니다.
*
* <p>MCP는 joni와 graal-js를 해석하지 않아 {@code pattern} 검증이 {@code java.util.regex}로 처리된다. 이 엔진은 백트래킹 기반이고 {@code Matcher.find()}로 모든 시작 위치를
* 시도하므로, 특정 정규식과 긴 입력의 조합에서 처리 시간이 다항·지수적으로 늘어난다. 인증이 없는 경계(ADR-0006)라 호출 빈도를 줄여 주는 계층도 없다.
*
* <p>규칙은 측정에 근거하며 안전을 증명하지 않는다. 근거와 한계는
* <a href="../../../../../../../../docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md">ADR-0012</a>에 있다.
*/
final class ToolSchemaPatternPolicy {
/**
* 허용할 정규식 길이 상한입니다. 업무 schema가 이보다 긴 정규식을 쓰는 경우는 사실상 없습니다.
*/
private static final int MAX_PATTERN_LENGTH = 512;
/**
* 정규식 하나가 가질 수 있는 무한 수량자({@code *}, {@code +}, {@code {n,}}) 개수 상한입니다. 겹치는 문자 집합에 이런 수량자가 k개 이어지면 비용이 입력 길이의 k제곱으로 늘어납니다. 측정상 4개부터는
* 아래 입력 상한 안에서도 초 단위로 넘어가고, 정상 업무 정규식이 3개를 넘는 경우는 드뭅니다.
*/
private static final int MAX_UNBOUNDED_QUANTIFIERS = 3;
/**
* {@code pattern}을 선언한 문자열 필드가 함께 선언해야 하는 {@code maxLength}의 상한입니다. 비용이 입력 길이에 달려 있으므로, 길이를 묶는 것이 정규식 모양을 검사하는 것보다 확실합니다.
*/
private static final int MAX_PATTERNED_STRING_LENGTH = 256;
private ToolSchemaPatternPolicy() {}
/**
* schema 전체를 훑어 {@code pattern} 정규식과 그 필드의 길이 제한을 검사하고, 길이를 묶을 수 없는 {@code patternProperties}는 거부합니다. schema가 없으면 통과시키고, 위반을 찾으면 해당 Tool을
* 등록하지 못하도록 {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로
* 적용됩니다.
*/
static void assertPatternsTerminateQuickly(JsonNode schema) {
if (schema == null || schema.isNull()) {
return;
}
Deque<JsonNode> pending = new ArrayDeque<>();
pending.push(schema);
while (!pending.isEmpty()) {
JsonNode node = pending.pop();
if (node.isObject()) {
assertKeywordPattern(node);
assertPatternPropertiesAbsent(node);
}
node.forEach(pending::push);
}
}
/**
* 한 schema 객체의 {@code pattern}과 그 필드의 {@code maxLength}를 함께 검사합니다. 값이 문자열이 아니면 정규식이 아니라 {@code properties} 아래에 우연히 같은 이름을 쓴 필드
* 정의이므로 건너뜁니다.
*/
private static void assertKeywordPattern(JsonNode node) {
JsonNode pattern = node.get("pattern");
if (pattern == null || !pattern.isTextual()) {
return;
}
assertSafeRegex(pattern.asText());
assertBoundedLength(node.get("maxLength"));
}
/**
* 정규식이 걸린 문자열의 길이가 묶여 있는지 확인합니다. 검증 비용이 입력 길이를 따라 늘어나므로, 상한이 없으면 정규식 모양과 무관하게 요청 body 한도(약 1MB)까지 열려 버립니다.
*/
private static void assertBoundedLength(JsonNode maxLength) {
if (maxLength == null || !maxLength.isIntegralNumber()) {
throw new IllegalStateException("Tool inputSchema pattern requires maxLength on the same field");
}
if (maxLength.intValue() <= 0 || maxLength.intValue() > MAX_PATTERNED_STRING_LENGTH) {
throw new IllegalStateException(
"Tool inputSchema maxLength with pattern must be at most " + MAX_PATTERNED_STRING_LENGTH);
}
}
/**
* {@code patternProperties}를 아예 거부합니다. 이 keyword는 값이 아니라 <em>입력 객체의 key</em>에 정규식을 적용하는데, key 길이를 선언할 자리가 없어 {@code pattern}에 쓴 길이
* 상한 방식을 그대로 적용할 수 없습니다. {@code propertyNames}로 길이를 묶는 방법은 keyword 평가 순서가 명세에 정해져 있지 않아 정규식이 먼저 돌 수 있으므로 통제로 쓰지 않습니다.
*
* <p>현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 것을 남겨 두는 대신 쓰지 않는 기능을 닫습니다. 실제 필요가 생기면 길이를 묶는 방법을 정한 새 ADR을 먼저 씁니다.
*/
private static void assertPatternPropertiesAbsent(JsonNode node) {
if (node.has("patternProperties")) {
throw new IllegalStateException("Tool inputSchema must not use patternProperties");
}
}
/**
* 정규식 하나가 길이 상한과 컴파일 가능성을 만족하고, 반복 구조가 허용 범위인지 확인합니다. 컴파일을 여기서 해 두면 잘못된 정규식이 요청 시점이 아니라 등록 시점에 걸립니다.
*/
private static void assertSafeRegex(String regex) {
if (regex.length() > MAX_PATTERN_LENGTH) {
throw new IllegalStateException(
"Tool inputSchema pattern must be at most " + MAX_PATTERN_LENGTH + " characters");
}
try {
Pattern.compile(regex);
} catch (PatternSyntaxException exception) {
throw new IllegalStateException("Tool inputSchema pattern is not a valid regular expression");
}
assertRepetitionIsBudgeted(regex);
}
/**
* 두 가지 반복 구조를 거부합니다.
*
* <ol>
* <li>무한 수량자를 품은 그룹을 다시 반복하는 형태. {@code (x+x+)+y}와 {@code (.*,){11}P}가 여기 해당하며, 바깥 반복 횟수에 상한이 있어도 측정상 폭증했으므로 {@code {11}} 같은
* 유한 반복도 함께 막습니다.
* <li>무한 수량자가 {@value #MAX_UNBOUNDED_QUANTIFIERS}개를 넘는 형태. {@code a*a*a*a*a*b}처럼 겹치는 문자 집합에 수량자가 이어지는 경우를 줄입니다.
* </ol>
*
* <p>겹침 여부까지 판정하지는 않으므로 이 검사만으로 안전이 보장되지 않습니다. 실질적인 상한은 함께 적용하는 {@code maxLength} 제한이 만듭니다.
*/
private static void assertRepetitionIsBudgeted(String regex) {
// 각 원소는 "지금까지 이 그룹 안에서 무한 수량자를 봤는가"다.
Deque<Boolean> openGroups = new ArrayDeque<>();
boolean insideCharacterClass = false;
int unboundedQuantifiers = 0;
int index = 0;
while (index < regex.length()) {
char current = regex.charAt(index);
if (current == '\\') {
// 이스케이프된 문자는 수량자도 그룹도 아니다.
index += 2;
continue;
}
if (insideCharacterClass) {
insideCharacterClass = current != ']';
index++;
continue;
}
if (current == '[') {
insideCharacterClass = true;
index++;
continue;
}
if (current == '(') {
openGroups.push(Boolean.FALSE);
index++;
continue;
}
if (current == ')') {
boolean groupHasUnbounded = !openGroups.isEmpty() && openGroups.pop();
int unbounded = unboundedQuantifierLength(regex, index + 1);
int bounded = unbounded > 0 ? 0 : boundedQuantifierLength(regex, index + 1);
if (groupHasUnbounded && (unbounded > 0 || bounded > 0)) {
throw new IllegalStateException(
"Tool inputSchema pattern must not repeat a group that already repeats without bound");
}
if (unbounded > 0) {
unboundedQuantifiers++;
markEnclosingGroup(openGroups);
}
index += 1 + unbounded + bounded;
continue;
}
int unbounded = unboundedQuantifierLength(regex, index);
if (unbounded > 0) {
unboundedQuantifiers++;
markEnclosingGroup(openGroups);
index += unbounded;
} else {
index++;
}
}
if (unboundedQuantifiers > MAX_UNBOUNDED_QUANTIFIERS) {
throw new IllegalStateException(
"Tool inputSchema pattern must use at most "
+ MAX_UNBOUNDED_QUANTIFIERS
+ " unbounded quantifiers");
}
}
/**
* 지금 열려 있는 그룹에 무한 수량자를 봤다고 기록합니다. 그룹 밖이면 중첩 판정 대상이 없습니다.
*/
private static void markEnclosingGroup(Deque<Boolean> openGroups) {
if (!openGroups.isEmpty()) {
openGroups.pop();
openGroups.push(Boolean.TRUE);
}
}
/**
* 주어진 위치에서 시작하는 무한 수량자({@code *}, {@code +}, {@code {n,}})의 길이를 반환하고, 아니면 0을 반환합니다.
*/
private static int unboundedQuantifierLength(String regex, int start) {
if (start >= regex.length()) {
return 0;
}
char current = regex.charAt(start);
if (current == '*' || current == '+') {
return 1;
}
if (current != '{') {
return 0;
}
int close = regex.indexOf('}', start);
if (close < 0) {
return 0;
}
return regex.substring(start + 1, close).endsWith(",") ? close - start + 1 : 0;
}
/**
* 주어진 위치에서 시작하는 상한 있는 수량자({@code ?}, {@code {n}}, {@code {n,m}})의 길이를 반환하고, 아니면 0을 반환합니다.
*/
private static int boundedQuantifierLength(String regex, int start) {
if (start >= regex.length()) {
return 0;
}
if (regex.charAt(start) == '?') {
return 1;
}
if (regex.charAt(start) != '{') {
return 0;
}
int close = regex.indexOf('}', start);
return close < 0 ? 0 : close - start + 1;
}
}

View File

@@ -0,0 +1,68 @@
package io.shinhanlife.dap.biz.mcp.registry;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* Tool의 {@code inputSchema}가 문서 밖을 가리키는 참조를 담지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 Portal 매니페스트, local 파일, Redis snapshot 중 어느 경로로 들어온
* schema든 같은 규칙을 통과합니다. JSON Schema 검증기는 문서 밖 참조를 만나면 그 주소로 직접 조회를 시도하므로, 매니페스트가 서버의 outbound 호출 대상을 정하는 통로가 되지 않도록 수신 시점에 끊습니다.
*/
final class ToolSchemaReferencePolicy {
/**
* MCP가 사용하는 유일한 JSON Schema dialect입니다. 다른 dialect를 선언하면 검증기가 그 meta-schema를 외부에서 조회할 수 있습니다.
*/
private static final String SUPPORTED_DIALECT = "https://json-schema.org/draft/2020-12/schema";
/**
* 참조 대상을 문서 안으로 한정하는 keyword입니다. 값이 {@code #}으로 시작하면 같은 문서 안의 위치를 가리킨다.
*/
private static final String[] REFERENCE_KEYWORDS = {"$ref", "$dynamicRef"};
private ToolSchemaReferencePolicy() {}
/**
* schema 전체를 훑어 문서 밖을 가리키는 참조가 있으면 거부합니다. schema가 없으면 검증할 것이 없으므로 그대로 통과시키고, 위반을 찾으면 해당 Tool을 등록하지 못하도록
* {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로 적용됩니다.
* 적대적으로 깊게 중첩된 schema에서도 스택이 무너지지 않도록 재귀 대신 명시적 스택으로 순회합니다.
*/
static void assertNoExternalReference(JsonNode schema) {
if (schema == null || schema.isNull()) {
return;
}
Deque<JsonNode> pending = new ArrayDeque<>();
pending.push(schema);
while (!pending.isEmpty()) {
JsonNode node = pending.pop();
if (node.isObject()) {
assertReferencesStayInDocument(node);
assertDialectIsSupported(node);
}
node.forEach(pending::push);
}
}
/**
* 한 schema 객체의 참조 keyword가 같은 문서 안을 가리키는지 확인합니다. 문자열이 아닌 값은 참조가 아니라 {@code properties} 아래의 필드 정의이므로 건너뜁니다.
*/
private static void assertReferencesStayInDocument(JsonNode node) {
for (String keyword : REFERENCE_KEYWORDS) {
JsonNode reference = node.get(keyword);
if (reference != null && reference.isTextual() && !reference.asText().startsWith("#")) {
throw new IllegalStateException(
"Tool inputSchema " + keyword + " must stay inside the document");
}
}
}
/**
* schema가 선언한 dialect가 MCP가 쓰는 2020-12인지 확인합니다. 선언이 없으면 검증기의 기본 dialect가 적용되므로 통과시킵니다.
*/
private static void assertDialectIsSupported(JsonNode node) {
JsonNode dialect = node.get("$schema");
if (dialect != null && dialect.isTextual() && !SUPPORTED_DIALECT.equals(dialect.asText())) {
throw new IllegalStateException("Tool inputSchema $schema must be " + SUPPORTED_DIALECT);
}
}
}

View File

@@ -85,7 +85,8 @@ mcp:
max-tool-timeout-millis: 30000
portal:
enabled: ${MCP_PORTAL_ENABLED:false}
route-key: ${MCP_PORTAL_ROUTE_KEY:}
# route key는 설정이 아니라 요청 URI의 /mcp/{routeKey}에서만 결정된다(ADR-0013).
# 기본 route로 보정하면 잘못된 단일 진입점 호출이 조용히 성공하므로 여기에 두지 않는다.
registry-url: ${MCP_PORTAL_REGISTRY_URL:}
refresh-interval-seconds: ${MCP_PORTAL_REFRESH_INTERVAL_SECONDS:300}
# Declared per deployment. baseEndpoint is the execution address and is owned by this file only:

View File

@@ -32,7 +32,7 @@ public final class TestFixtures {
new McpProperties.Trace(true, 1_048_576),
new McpProperties.Protocol(List.of("2025-11-25"), "2025-11-25"),
new McpProperties.Discovery(!bundles.isEmpty(), 1_000, 3_000, 100, 200, 1_048_576, 30_000),
new McpProperties.Portal(false, "", "", 300),
new McpProperties.Portal(false, "", 300),
bundles);
}

View File

@@ -68,7 +68,7 @@ class McpBundleConfigurationTest {
null,
null,
new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000),
new McpProperties.Portal(false, "", "", 300),
new McpProperties.Portal(false, "", 300),
List.of());
assertThat(properties.isDiscoveryTargetDeclared()).isFalse();

View File

@@ -0,0 +1,172 @@
package io.shinhanlife.dap.biz.mcp.contract;
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
import static io.shinhanlife.dap.biz.mcp.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
import io.shinhanlife.dap.biz.mcp.registry.PortalToolRegistryClient;
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery;
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* Portal-MCP 계약 문서의 registry 예제 JSON을 직접 읽어 구현이 그 계약을 그대로 만족하는지 검증하는 계약 테스트입니다.
* 문서와 코드가 각자 표류하는 것을 막는 것이 목적이므로, 예제 파일을 고치면 이 테스트가 함께 깨져야 합니다.
* 예제의 {@code serviceDomain}만 MockWebServer 주소로 치환하고 나머지 필드는 파일 그대로 사용하므로,
* serviceDomain과 manifestPath를 조합해 매니페스트를 조회하는 실제 경로가 그대로 실행됩니다.
*/
class PortalRegistryContractExampleTest {
private static final Path EXAMPLES = Path.of("docs/contracts/portal-mcp/examples/registry-v0.1");
private static final String BUSINESS_DOMAIN = "http://tool-business.ax-hub.svc.cluster.local:8080";
private static final String EXTERNAL_DOMAIN = "http://tool-external.ax-hub.svc.cluster.local:8080";
private MockWebServer portal;
private MockWebServer businessToolServer;
private MockWebServer externalToolServer;
/**
* 예제 registry 응답을 돌려줄 포털과 route별 Tool Service를 각각 띄웁니다.
* route마다 서버를 분리해, 조회 순서에 의존하지 않고 route별 매니페스트 조회 대상을 검증할 수 있게 합니다.
*/
@BeforeEach
void setUp() throws Exception {
portal = new MockWebServer();
portal.start();
businessToolServer = new MockWebServer();
businessToolServer.start();
externalToolServer = new MockWebServer();
externalToolServer.start();
}
/**
* 띄운 서버를 정리합니다.
*/
@AfterEach
void tearDown() throws Exception {
portal.shutdown();
businessToolServer.shutdown();
externalToolServer.shutdown();
}
@Test
void buildsRouteCatalogFromTheContractAggregateExampleExactlyAsDocumented() throws Exception {
portal.enqueue(json(exampleWithLocalDomains("aggregate-registry-response.json")));
businessToolServer.enqueue(json(manifest("business-tools", "business.customer_search")));
externalToolServer.enqueue(json(manifest("external-tools", "external.weather_lookup")));
Map<String, List<ToolMetadata>> catalog = client().fetchAllTools();
assertThat(catalog).containsOnlyKeys("business", "external");
assertThat(catalog.get("business")).extracting(ToolMetadata::name)
.containsExactly("business.customer_search");
assertThat(catalog.get("external")).extracting(ToolMetadata::name)
.containsExactly("external.weather_lookup");
assertThat(businessToolServer.takeRequest().getPath()).isEqualTo("/tool-manifest");
assertThat(externalToolServer.takeRequest().getPath()).isEqualTo("/tool-manifest");
}
@Test
void readsTheContractSingleRouteExampleAsOneRouteDocument() throws Exception {
portal.enqueue(json(exampleWithLocalDomains("route-registry-response.json")));
externalToolServer.enqueue(json(manifest("external-tools", "external.exchange_rate")));
List<ToolMetadata> tools = client().fetchTools("external");
assertThat(tools).extracting(ToolMetadata::name).containsExactly("external.exchange_rate");
assertThat(externalToolServer.takeRequest().getPath()).isEqualTo("/tool-manifest");
assertThat(businessToolServer.getRequestCount()).isZero();
}
/**
* 계약 예제 JSON을 읽어 문서용 service domain만 실제 MockWebServer 주소로 치환합니다.
* 나머지 필드를 그대로 두어야 예제가 구현의 입력으로 검증되므로 domain 외의 값은 바꾸지 않습니다.
*/
private String exampleWithLocalDomains(String fileName) throws Exception {
return Files.readString(EXAMPLES.resolve(fileName))
.replace(BUSINESS_DOMAIN, trimTrailingSlash(businessToolServer.url("").toString()))
.replace(EXTERNAL_DOMAIN, trimTrailingSlash(externalToolServer.url("").toString()));
}
/**
* 포털 registry 응답만 계약 예제로 고정하고, Tool Service 매니페스트는 이 계약의 소유가 아니므로 최소 형태로 만듭니다.
*/
private String manifest(String bundleId, String toolName) {
return """
{
"bundleId": "%s",
"revision": "manifest-1",
"tools": [
{
"name": "%s",
"description": "contract example tool",
"inputSchema": {
"type": "object",
"properties": {
"value": {
"type": "string"
}
}
},
"_meta": {
"version": "1.0.0",
"enabled": true
}
}
]
}
"""
.formatted(bundleId, toolName);
}
/**
* 계약 문서 3절의 설정 예시와 같은 상태, 즉 포털이 endpoint 원천이고 {@code mcp.bundles}가 비어 있는 client를 만듭니다.
*/
private PortalToolRegistryClient client() {
RestClient restClient = RestClient.builder()
.requestFactory(new SimpleClientHttpRequestFactory())
.build();
McpProperties base = properties(false, false);
McpProperties mcpProperties = new McpProperties(
base.identity(),
base.endpointPath(),
base.server(),
base.registry(),
base.toolClient(),
base.redis(),
base.trace(),
base.protocol(),
new McpProperties.Discovery(true, 1_000, 3_000, 100, 200, 1_048_576, 30_000),
new McpProperties.Portal(true, portal.url("/api/portal/registry").toString(), 300),
List.of());
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
return new PortalToolRegistryClient(restClient, mcpProperties, discovery, Optional.empty());
}
/**
* MockWebServer가 돌려주는 base URL 끝의 slash를 제거해 예제의 service domain 형태와 맞춥니다.
*/
private String trimTrailingSlash(String value) {
return value.replaceAll("/+$", "");
}
/**
* 계약 예제를 JSON 응답으로 감쌉니다.
*/
private MockResponse json(String body) {
return new MockResponse().setHeader("Content-Type", "application/json").setBody(body);
}
}

View File

@@ -20,7 +20,7 @@ import org.yaml.snakeyaml.Yaml;
* Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라
* CrashLoopBackOff로 나타납니다. 같은 규칙을 여기서 먼저 적용해 잘못된 values가 머지되는 것을 막습니다.
*
* <p>이 테스트가 고정하는 핵심 규칙은 MCP 배포와 Tool Service 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라
* <p>이 테스트가 고정하는 핵심 규칙은 두 배포 모델(portal·bundles)의 경계, portal 모드에서 route key가 배포 정의로 새지 않는 것, bundles 모드의 Tool Service 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라
* 배포 정의에서 잠급니다. 파일을 읽기만 하며 애플리케이션 context나 helm 바이너리를 필요로 하지 않습니다.
*/
class HelmDeploymentContractTest {
@@ -302,6 +302,92 @@ class HelmDeploymentContractTest {
.contains("replicas: {{ $tier.replicas }}");
}
/**
* Chart가 지원하는 배포 모델만 선언하는지 확인합니다. {@code mode}는 template 전체의 분기 축이므로, 오타가 있으면 렌더링이 통째로 실패하거나 더 나쁘게는 의도하지 않은 모델로 설치됩니다.
*/
@Test
void valuesDeclareASupportedDeploymentMode() throws IOException {
Map<String, Object> values = loadYaml(VALUES);
assertThat(String.valueOf(values.get("mode")))
.withFailMessage("values.yaml의 mode는 portal 또는 bundles여야 합니다: %s", values.get("mode"))
.isIn("portal", "bundles");
}
/**
* portal 모드의 단일 배포가 이름·등급·공개 path를 선언하고, 그 path가 route key를 포함하지 않는지 확인합니다. route key는 {@code /mcp/{routeKey}} URI에서만 결정되므로(ADR-0013) publicPath에 route를 적으면 그 배포는 한 route만
* 받게 되어 Portal이 매핑을 소유하는 의미가 사라집니다.
*/
@Test
void portalDeploymentServesEveryRouteUnderTheBareMcpPath() throws IOException {
Map<String, Object> values = loadYaml(VALUES);
Map<String, Object> deployment = asMap(section(values, "portal").get("deployment"));
assertThat(deployment)
.withFailMessage("portal.deployment에 name/tier/publicPath가 모두 있어야 합니다: %s", deployment)
.containsKeys("name", "tier", "publicPath");
assertThat(String.valueOf(deployment.get("publicPath")))
.withFailMessage("portal.deployment.publicPath에 route key가 들어 있습니다. route는 URI에서만 옵니다(ADR-0013).")
.isEqualTo("/mcp");
assertThat(section(values, "tiers").keySet())
.withFailMessage("portal.deployment의 tier '%s'가 tiers에 없습니다.", deployment.get("tier"))
.contains(String.valueOf(deployment.get("tier")));
}
/**
* Portal registry 주소에 기본값이 없고 환경별 values가 각자 선언하는지 확인합니다. 기본값이 있으면 지정을 빠뜨린 설치가 조용히 성공해 dev가 운영 Portal을 보거나 그 반대가 됩니다.
*/
@ParameterizedTest
@ValueSource(strings = {"dev", "test", "prod"})
void everyEnvironmentDeclaresItsOwnPortalRegistryUrl(String env) throws IOException {
Object shared = section(loadYaml(VALUES), "portal").get("registryUrl");
assertThat(shared == null || String.valueOf(shared).isEmpty())
.withFailMessage("values.yaml이 portal.registryUrl 기본값 '%s'를 갖고 있습니다. 환경별 파일이 소유해야 합니다.", shared)
.isTrue();
String url = String.valueOf(section(loadYaml(environmentValues(env)), "portal").get("registryUrl"));
assertThat(url)
.withFailMessage("values-%s.yaml에 portal.registryUrl이 없습니다. mode=portal 렌더링이 실패합니다.", env)
.isNotBlank()
.doesNotContain("null")
.startsWith("http");
}
/**
* ConfigMap이 두 모델을 모두 만들되 서로 섞이지 않는지 확인합니다. portal 분기는 정적 bundle을 선언하지 않아야 하고, route key를 ConfigMap에 적어서는 안 됩니다. 적는 순간 매핑이 다시 배포 시점으로 고정되어 Portal을 원천으로 둔
* 이유가 사라집니다.
*/
@Test
void configMapKeepsPortalAndBundleSourcesSeparate() throws IOException {
String configMap = Files.readString(CHART.resolve("templates/configmap.yaml"));
assertThat(configMap)
.contains("{{- if eq .Values.mode \"portal\" }}")
.contains("bundles: []")
.contains("registry-url: {{ .Values.portal.registryUrl | quote }}")
.contains("portal-registry-key: {{ .Values.portal.registryRedisKey | quote }}");
assertThat(configMap)
.withFailMessage("ConfigMap이 route key를 직접 적고 있습니다. route는 /mcp/{routeKey} URI에서만 옵니다(ADR-0013).")
.doesNotContain("route-key");
}
/**
* 검사 helper가 확인한 값을 출력하지 않는지 확인합니다. {@code required}의 결과를 변수에 담지 않으면 검사한 host와 CIDR이 렌더링 결과에 그대로 섞여 리소스 이름과 path가 오염됩니다. 정적 values 검사로는 드러나지 않고 렌더링해야 보이는
* 종류의 실수이므로 여기서 형태를 고정합니다.
*/
@Test
void validationHelperDoesNotEmitTheValuesItChecks() throws IOException {
List<String> emitting =
Files.readAllLines(CHART.resolve("templates/_helpers.tpl")).stream()
.map(String::trim)
.filter(line -> line.startsWith("{{- required") || line.startsWith("{{ required"))
.toList();
assertThat(emitting)
.withFailMessage("required 결과를 변수에 담지 않아 검사 값이 렌더링 결과에 출력됩니다: %s", emitting)
.isEmpty();
}
/**
* 환경별 values 파일 경로를 만듭니다.
*/

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dap.biz.mcp.execute;
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
@@ -74,4 +75,38 @@ class ToolArgumentValidatorTest {
assertThat(exception.errorData().toString()).doesNotContain("unexpected");
});
}
@Test
void doesNotAssertFormatSoToolServicesCannotRelyOnIt() throws Exception {
// JSON Schema 2020-12에서 format은 기본이 주석이고, SDK 검증기도 단언하지 않는다.
// 두 가지를 고정하기 위한 테스트다.
// 1. Tool Service가 format을 입력 검증 수단으로 기대하면 안 된다는 사실.
// 2. format:regex는 입력 값을 정규식으로 컴파일할 수 있는 형태인데, 단언이 꺼져 있어
// 그 경로가 실행되지 않는다는 사실. SDK 업그레이드나 설정 변경으로 단언이 켜지면
// 이 테스트가 실패하므로, 그때 ADR-0012의 정규식 예산과 함께 다시 판단한다.
ToolCall call =
new ToolCall(
"document.search",
OBJECT_MAPPER.readTree("""
{"issuedAt":"not-a-date","expr":"([unclosed"}
"""));
ToolMetadata metadata =
new ToolMetadata(
"document.search",
"1.0.0",
"Search documents",
"http://tool.example/search",
OBJECT_MAPPER.readTree(
"""
{"type":"object","properties":{
"issuedAt":{"type":"string","format":"date-time"},
"expr":{"type":"string","format":"regex"}}}
"""),
3_000,
true,
null);
assertThatCode(() -> validator.validate(call, metadata)).doesNotThrowAnyException();
}
}

View File

@@ -25,6 +25,7 @@ class PortalToolRegistryClientTest {
private MockWebServer portal;
private MockWebServer toolServer;
private MockWebServer brokenToolServer;
@BeforeEach
void setUp() throws Exception {
@@ -32,12 +33,15 @@ class PortalToolRegistryClientTest {
portal.start();
toolServer = new MockWebServer();
toolServer.start();
brokenToolServer = new MockWebServer();
brokenToolServer.start();
}
@AfterEach
void tearDown() throws Exception {
portal.shutdown();
toolServer.shutdown();
brokenToolServer.shutdown();
}
@Test
@@ -107,6 +111,70 @@ class PortalToolRegistryClientTest {
assertThat(toolServer.getRequestCount()).isZero();
}
@Test
void keepsHealthyRoutesWhenAnotherRouteToolServiceNeverSucceeds() {
portal.enqueue(jsonResponse(twoRoutePortalRegistryJson("portal-1")));
toolServer.enqueue(manifest("manifest-1", "external.weather"));
brokenToolServer.enqueue(new MockResponse().setResponseCode(503));
Map<String, List<ToolMetadata>> snapshots = client().fetchAllTools();
assertThat(snapshots).containsOnlyKeys("external");
assertThat(snapshots.get("external"))
.extracting(ToolMetadata::name)
.containsExactly("external.weather");
}
@Test
void reportsEveryRouteTheRegistryDeclaredEvenWhenItsCatalogFailed() {
portal.enqueue(jsonResponse(twoRoutePortalRegistryJson("portal-1")));
toolServer.enqueue(manifest("manifest-1", "external.weather"));
brokenToolServer.enqueue(new MockResponse().setResponseCode(503));
PortalToolRegistryClient client = client();
client.fetchAllTools();
assertThat(client.knownRoutes()).containsExactlyInAnyOrder("external", "broken");
}
private String twoRoutePortalRegistryJson(String revision) {
return """
{
"registryRevision": "%s",
"routes": [
{
"routeKey": "external",
"toolServices": [
{
"serviceKey": "external-tool-server",
"serviceDomain": "%s",
"manifestPath": "/tool-manifest",
"namePrefix": "external.",
"status": "ACTIVE"
}
]
},
{
"routeKey": "broken",
"toolServices": [
{
"serviceKey": "broken-tool-server",
"serviceDomain": "%s",
"manifestPath": "/tool-manifest",
"namePrefix": "broken.",
"status": "ACTIVE"
}
]
}
]
}
"""
.formatted(
revision,
toolServer.url("").toString().replaceAll("/+$", ""),
brokenToolServer.url("").toString().replaceAll("/+$", ""));
}
private PortalToolRegistryClient client() {
return client(Optional.empty());
}
@@ -126,7 +194,7 @@ class PortalToolRegistryClientTest {
base.trace(),
base.protocol(),
base.discovery(),
new McpProperties.Portal(true, "", portal.url("/api/portal/registry").toString(), 15),
new McpProperties.Portal(true, portal.url("/api/portal/registry").toString(), 15),
List.of());
ToolBundleDiscovery discovery = new ToolBundleDiscovery(restClient, OBJECT_MAPPER, mcpProperties);
return new PortalToolRegistryClient(restClient, mcpProperties, discovery, redisPortalRegistryCache);

View File

@@ -84,6 +84,31 @@ class ToolBundleDiscoveryTest {
.satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp"));
}
@Test
void rejectsAManifestWhoseInputSchemaReferencesAnExternalDocument() {
// endpoint와 마찬가지로 schema 참조도 매니페스트가 outbound 대상을 정하는 통로가 되면 안 된다.
alpha.enqueue(
new MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{"bundleId":"bundle-a","tools":[
{"name":"a.search","description":"search",
"inputSchema":{"type":"object",
"properties":{"q":{"$ref":"http://attacker.example/schema.json"}}},
"_meta":{"version":"1.0.0"}}]}
"""));
McpProperties properties =
withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
assertThatThrownBy(() -> client(properties).fetchTools())
.isInstanceOfSatisfying(
JsonRpcException.class,
exception ->
assertThat(exception.errorCode())
.isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
}
@Test
void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() {
alpha.enqueue(new MockResponse().setResponseCode(503));

View File

@@ -1,14 +1,29 @@
package io.shinhanlife.dap.biz.mcp.registry;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
class ToolRegistryRefreshSchedulerTest {
@Test
void loadsRouteRegistryBeforeWarmStartSoRouteScopedCacheKeysAreKnown() {
ToolRegistryService service = mock(ToolRegistryService.class);
ToolRegistryRefreshScheduler scheduler = new ToolRegistryRefreshScheduler(service);
scheduler.preload();
InOrder order = inOrder(service);
order.verify(service).refreshSourceRegistry();
order.verify(service).warmStartFromSharedCache();
order.verify(service).refreshKnownRoutes();
}
@Test
void refreshesManifestImmediatelyWhenPortalRegistryChanges() {
ToolRegistryService service = mock(ToolRegistryService.class);

View File

@@ -18,6 +18,7 @@ import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@@ -150,7 +151,26 @@ class ToolRegistryServiceTest {
.extracting(ToolMetadata::endpoint)
.isEqualTo("http://shared-tool");
verify(redis, times(1)).loadSnapshot("");
verifyNoInteractions(client);
// warm start는 route 목록만 물어보고 원천 조회는 하지 않는다.
verify(client, never()).fetchTools(any());
verify(client, never()).fetchAllTools();
}
@Test
void warmStartsEveryRouteTheSourceDeclares() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
RedisToolRegistryCache redis = mock(RedisToolRegistryCache.class);
when(client.knownRoutes()).thenReturn(Set.of("external", "business"));
when(redis.loadSnapshot("external"))
.thenReturn(Optional.of(List.of(tool("http://external-tool"))));
when(redis.loadSnapshot("business"))
.thenReturn(Optional.of(List.of(tool("http://business-tool"))));
ToolRegistryService service = new ToolRegistryService(client, Optional.of(redis));
service.warmStartFromSharedCache();
assertThat(service.readyRouteKeys()).containsExactlyInAnyOrder("external", "business");
verify(redis, never()).loadSnapshot("");
}
@Test
@@ -226,6 +246,47 @@ class ToolRegistryServiceTest {
verify(client, never()).fetchTools("business");
}
@Test
void keepsSnapshotOfARouteWhoseCatalogFailedWhileTheSourceStillDeclaresIt() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
when(client.knownRoutes()).thenReturn(Set.of("external", "business"));
when(client.fetchAllTools())
.thenReturn(Map.of(
"external", List.of(tool("http://external-tool")),
"business", List.of(tool("http://business-tool"))))
.thenReturn(Map.of("external", List.of(tool("http://external-tool"))));
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
service.refreshKnownRoutes();
service.refreshKnownRoutes();
assertThat(service.listTools("business"))
.singleElement()
.extracting(ToolMetadata::endpoint)
.isEqualTo("http://business-tool");
assertThat(service.readyRouteKeys()).containsExactlyInAnyOrder("external", "business");
verify(client, never()).fetchTools("business");
}
@Test
void removesRouteOnlyWhenTheSourceStopsDeclaringIt() {
ToolRegistryClient client = mock(ToolRegistryClient.class);
when(client.knownRoutes())
.thenReturn(Set.of("external", "business"))
.thenReturn(Set.of("external"));
when(client.fetchAllTools())
.thenReturn(Map.of(
"external", List.of(tool("http://external-tool")),
"business", List.of(tool("http://business-tool"))))
.thenReturn(Map.of("external", List.of(tool("http://external-tool"))));
ToolRegistryService service = new ToolRegistryService(client, Optional.empty());
service.refreshKnownRoutes();
service.refreshKnownRoutes();
assertThat(service.readyRouteKeys()).containsExactly("external");
}
@Test
void publishesToolsListChangedOnlyWhenExistingRouteSnapshotChanges() {
ToolRegistryClient client = mock(ToolRegistryClient.class);

View File

@@ -0,0 +1,166 @@
package io.shinhanlife.dap.biz.mcp.registry;
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
/**
* 매니페스트가 준 정규식이 요청 스레드를 오래 붙잡지 못한다는 불변식을 고정하는 테스트입니다. 여기 실린 위험 정규식은 모두 JDK 21에서 실제로 되돌아오지 않는 것이 확인된 형태이고, 정상 업무 정규식이 함께 막히지
* 않는지도 같이 검사합니다. 검사는 {@link ToolMetadata} 생성 시점에 걸리므로 Portal·local·Redis 중 어느 경로로 들어와도 같은 규칙이 적용됩니다.
*/
class ToolSchemaPatternPolicyTest {
@Test
void rejectsRepeatingAGroupThatAlreadyRepeatsWithoutBound() {
// (x+x+)+y 는 입력 1000자에서 되돌아오지 않았다.
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"q":{"type":"string","maxLength":64,"pattern":"(x+x+)+y"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("repeats without bound");
}
@Test
void rejectsABoundedRepetitionOfAnUnboundedGroup() {
// (.*,){11}P 는 바깥 반복이 11회로 묶여 있어도 입력 1000자에서 되돌아오지 않았다.
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"q":{"type":"string","maxLength":64,"pattern":"(.*,){11}P"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("repeats without bound");
}
@Test
void rejectsMoreUnboundedQuantifiersThanTheBudget() {
// a*a*a*a*a*b 는 입력 100자에서 이미 되돌아오지 않았다.
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"q":{"type":"string","maxLength":64,"pattern":"a*a*a*a*a*b"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("unbounded quantifiers");
}
@Test
void rejectsAPatternedFieldWithoutALengthBound() {
// 모양 검사만으로는 겹치는 문자 집합을 가려낼 수 없어, 길이 상한이 실질적인 방어선이다.
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"q":{"type":"string","pattern":"^[0-9]+$"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("requires maxLength");
}
@Test
void rejectsALengthBoundLargeEnoughToStillHurt() {
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"q":{"type":"string","maxLength":100000,"pattern":"^[0-9]+$"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("256");
}
@Test
void rejectsPatternPropertiesOutright() {
// 입력 객체의 key가 대상이라 길이를 묶을 자리가 없다. 안 쓰는 keyword라 통째로 닫는다.
assertThatThrownBy(() -> metadata("""
{"type":"object","patternProperties":{"^[a-z]+$":{"type":"string"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("patternProperties");
}
@Test
void rejectsPatternPropertiesEvenWhenPropertyNamesLooksBounded() {
// propertyNames로 길이를 묶어도 keyword 평가 순서가 명세에 없어 정규식이 먼저 돌 수 있다.
assertThatThrownBy(() -> metadata("""
{"type":"object","propertyNames":{"maxLength":16},
"patternProperties":{"^(a+a+)+$":{"type":"string"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("patternProperties");
}
@Test
void rejectsPatternPropertiesNestedBelowTheRoot() {
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"filter":{"type":"object",
"patternProperties":{"^k":{"type":"string"}}}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("patternProperties");
}
@Test
void rejectsAPatternThatDoesNotCompile() {
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"q":{"maxLength":64,"pattern":"([unclosed"}}}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("valid regular expression");
}
@Test
void rejectsAnExcessivelyLongPattern() throws Exception {
JsonNode schema = OBJECT_MAPPER.readTree(
"{\"type\":\"object\",\"properties\":{\"q\":{\"maxLength\":64,\"pattern\":\""
+ "a".repeat(513) + "\"}}}");
assertThatThrownBy(() -> metadata(schema))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("512");
}
@Test
void acceptsOrdinaryBusinessPatterns() {
// 실제 업무 schema가 이 정책 때문에 막히면 안 된다.
assertThatCode(() -> metadata("""
{"type":"object","properties":{
"customerNo":{"type":"string","maxLength":10,"pattern":"^[0-9]{10}$"},
"email":{"type":"string","maxLength":128,"pattern":"^[^@ ]+@[^@ ]+$"},
"code":{"type":"string","maxLength":64,"pattern":"^([A-Z]{3}-)+[0-9]+$"}}}
"""))
.doesNotThrowAnyException();
}
@Test
void keepsQuantifierCharactersInsideACharacterClassLiteral() {
// "[+*]"의 +와 *는 수량자가 아니라 문자다. 오탐으로 정상 Tool을 막으면 안 된다.
assertThatCode(() -> metadata("""
{"type":"object","properties":{"op":{"type":"string","maxLength":32,"pattern":"^[+*]+$"}}}
"""))
.doesNotThrowAnyException();
}
@Test
void leavesFieldsWithoutAPatternAlone() {
// pattern이 없으면 maxLength를 요구하지 않는다.
assertThatCode(() -> metadata("""
{"type":"object","properties":{"memo":{"type":"string"}}}
"""))
.doesNotThrowAnyException();
}
@Test
void acceptsAToolWithoutAnyInputSchema() {
assertThatCode(() -> metadata((JsonNode) null)).doesNotThrowAnyException();
}
/**
* JSON 문자열을 schema로 갖는 {@link ToolMetadata}를 만들어 생성 시점 검사를 태웁니다.
*/
private static ToolMetadata metadata(String schemaJson) throws JsonProcessingException {
return metadata(OBJECT_MAPPER.readTree(schemaJson));
}
/**
* 검사 대상 schema 외의 필드는 실행에 영향을 주지 않는 고정값으로 채웁니다.
*/
private static ToolMetadata metadata(JsonNode schema) {
return new ToolMetadata(
"a.search", "1.0.0", "search", "http://tool-a/mcp", schema, 1_000, true, null);
}
}

View File

@@ -0,0 +1,111 @@
package io.shinhanlife.dap.biz.mcp.registry;
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
/**
* 매니페스트가 준 {@code inputSchema}로는 서버의 outbound 호출 대상을 정할 수 없다는 불변식을 고정하는 테스트입니다. 검사는 {@link ToolMetadata} 생성 시점에 걸리므로 Portal·local·Redis 중 어느
* 경로로 들어와도 같은 규칙이 적용되는지를 값 객체 수준에서 확인합니다.
*/
class ToolSchemaReferencePolicyTest {
@Test
void rejectsASchemaThatReferencesAnAbsoluteUrl() {
assertThatThrownBy(() -> metadata("""
{"type":"object","$ref":"http://attacker.example/schema.json"}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("$ref");
}
@Test
void rejectsAReferenceHiddenDeepInsideNestedProperties() {
// 최상위만 보는 검사로는 막히지 않는 위치다.
assertThatThrownBy(() -> metadata("""
{"type":"object","properties":{"outer":{"type":"object",
"properties":{"inner":{"$ref":"https://attacker.example/deep.json"}}}}}
"""))
.isInstanceOf(IllegalStateException.class);
}
@Test
void rejectsAReferenceInsideAnArrayKeyword() {
assertThatThrownBy(() -> metadata("""
{"type":"object","allOf":[{"$ref":"//attacker.example/protocol-relative.json"}]}
"""))
.isInstanceOf(IllegalStateException.class);
}
@Test
void rejectsADynamicReferenceThatLeavesTheDocument() {
assertThatThrownBy(() -> metadata("""
{"type":"object","$dynamicRef":"https://attacker.example/dynamic.json#node"}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("$dynamicRef");
}
@Test
void rejectsADialectOtherThanDraft202012() {
// 낯선 dialect를 선언하면 검증기가 그 meta-schema를 외부에서 받아오려 할 수 있다.
assertThatThrownBy(() -> metadata("""
{"$schema":"https://attacker.example/meta.json","type":"object"}
"""))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("$schema");
}
@Test
void acceptsAReferenceThatStaysInsideTheDocument() throws Exception {
JsonNode schema = OBJECT_MAPPER.readTree("""
{"type":"object","properties":{"customer":{"$ref":"#/$defs/customer"}},
"$defs":{"customer":{"type":"string"}}}
""");
assertThatCode(() -> metadata(schema)).doesNotThrowAnyException();
}
@Test
void acceptsTheDeclaredDraft202012Dialect() {
assertThatCode(() -> metadata("""
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}
"""))
.doesNotThrowAnyException();
}
@Test
void acceptsAToolWithoutAnyInputSchema() {
assertThatCode(() -> metadata((JsonNode) null)).doesNotThrowAnyException();
}
@Test
void keepsAPropertyLiterallyNamedRefUsable() throws Exception {
// "$ref"라는 이름의 필드 정의는 참조가 아니라 일반 property이므로 막히면 안 된다.
JsonNode schema = OBJECT_MAPPER.readTree("""
{"type":"object","properties":{"$ref":{"type":"string"}}}
""");
assertThat(metadata(schema).inputSchema().path("properties").has("$ref")).isTrue();
}
/**
* JSON 문자열을 schema로 갖는 {@link ToolMetadata}를 만들어 생성 시점 검사를 태웁니다.
*/
private static ToolMetadata metadata(String schemaJson) throws JsonProcessingException {
return metadata(OBJECT_MAPPER.readTree(schemaJson));
}
/**
* 검사 대상 schema 외의 필드는 실행에 영향을 주지 않는 고정값으로 채웁니다.
*/
private static ToolMetadata metadata(JsonNode schema) {
return new ToolMetadata(
"a.search", "1.0.0", "search", "http://tool-a/mcp", schema, 1_000, true, null);
}
}

View File

@@ -302,7 +302,7 @@ class McpExchangeFilterTest {
base.trace(),
base.protocol(),
base.discovery(),
new McpProperties.Portal(true, "", "http://portal.test/api/registry", 15),
new McpProperties.Portal(true, "http://portal.test/api/registry", 15),
base.bundles());
McpExchangeFilter filter = filter(portalEnabled);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");