Compare commits
9 Commits
main
...
archive/fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f60f4f01b | |||
| 37fc0d5ebe | |||
| d31e5ce0ab | |||
| 7afb4d9e05 | |||
| 5c069a0ee7 | |||
| 6424f25917 | |||
| d64516b50a | |||
| 76ed199499 | |||
| 1e6fa8f22f |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -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
82
.gitea/workflows/ci.yaml
Normal 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"
|
||||
114
.gitea/workflows/deploy-openshift.yaml
Normal file
114
.gitea/workflows/deploy-openshift.yaml
Normal 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
4
.gitignore
vendored
@@ -38,4 +38,8 @@ secrets/
|
||||
# Claude Code: 공유 설정(.claude/settings.json)은 커밋하고 개인 설정은 제외한다.
|
||||
.claude/settings.local.json
|
||||
|
||||
# 에이전트 도구가 만드는 로컬 캐시·잠금 파일. 저장소 산출물이 아니다.
|
||||
.ua/
|
||||
skills-lock.json
|
||||
|
||||
|
||||
|
||||
105
README.md
105
README.md
@@ -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)의 완료 기준이 정본이다.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
186
deploy/README.md
186
deploy/README.md
@@ -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#운영-적용-전-필수-보완)에서 관리한다.
|
||||
|
||||
59
deploy/ci/render-manifests.sh
Normal file
59
deploy/ci/render-manifests.sh
Normal 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"
|
||||
277
deploy/examples/axhub-mcp-dev-manual.template.yaml
Normal file
277
deploy/examples/axhub-mcp-dev-manual.template.yaml
Normal 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
|
||||
BIN
deploy/examples/axhub-mcp-dev-manual.template.zip
Normal file
BIN
deploy/examples/axhub-mcp-dev-manual.template.zip
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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" -}}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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을 영구히 막는다.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`로 확인한다.
|
||||
|
||||
|
||||
@@ -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)
|
||||
를 따른다.
|
||||
|
||||
## 호환성 메모
|
||||
|
||||
|
||||
51
docs/contracts/portal-mcp/README.md
Normal file
51
docs/contracts/portal-mcp/README.md
Normal 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)을 따른다.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: []
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
227
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal file
227
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal 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 주소로 치환하며,
|
||||
나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**
|
||||
@@ -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`를 생략한다.
|
||||
|
||||
@@ -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 결정은 그대로 유지된다.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 배경
|
||||
|
||||
@@ -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`가 잠근다.
|
||||
88
docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md
Normal file
88
docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md
Normal 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`가 잠근다. 위 측정을 다시
|
||||
하지 않고 한도를 바꾸지 않는다.
|
||||
@@ -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 추가가 배포 파이프라인을 건드린다.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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/`에 있어 이 저장소가 내용을 모른다
|
||||
|
||||
## 운영 적용 전 필수 보완
|
||||
|
||||
|
||||
@@ -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 기능
|
||||
|
||||
|
||||
706
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json
Normal file
706
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx
Normal file
BIN
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx
Normal file
Binary file not shown.
74
docs/sbom/README.md
Normal file
74
docs/sbom/README.md
Normal 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`에 등장하는지 구조 점검을 통과시켰다.
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 규칙을 호출자에게 전달합니다.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 목록을 읽습니다.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 怨듬갚??硫붿썎?덈떎.
|
||||
* route별 in-memory snapshot에서 활성 Tool 목록을 읽습니다.
|
||||
* 요청 route의 snapshot이 없으면 해당 route만 Registry 원천에서 한 번 조회해 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 snapshot이 memory에 존재하는지 반환합니다. 원천 또는 Redis에서 성공적으로 채택한 값이 목록에 유효한 전체 상태이므로 {@code null} 여부만으로 판단하며, readiness 확인 과정에서 Redis나 Tool 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????理쒖쥌 ?먮떒?⑸땲??
|
||||
* 요청 route의 Tool snapshot에서 이름이 일치하는 활성 Tool 하나를 찾습니다.
|
||||
* route별 cache가 오래됐을 수 있으므로 최초 miss 시 해당 route만 refresh한 뒤 최종 판단합니다.
|
||||
*/
|
||||
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???쒕줈 ?낅┰?곸쑝濡?媛깆떊?⑸땲??
|
||||
* 지정한 route의 Registry 원천을 직접 읽어 route별 snapshot을 갱신합니다.
|
||||
* 같은 route의 동시 refresh는 single-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 원천을 한 번 조회하고 성공한 전체 snapshot만 memory와 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 ?꾩껜瑜?湲곕줉?⑸땲??
|
||||
* 로컬 검증을 위해 route별 in-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 ?깃났 ?щ????곹뼢??二쇱? ?딅룄濡??ㅽ뙣 ??理쒖냼 臾몄옄???쒗쁽?쇰줈 ?泥댄빀?덈떎.
|
||||
* 검증 로그에 사용할 route별 snapshot 내용을 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 key의 null과 공백을 기존 단일 snapshot key인 빈 문자열로 정규화합니다.
|
||||
*/
|
||||
private String normalizeRouteKey(String routeKey) {
|
||||
return routeKey == null ? "" : routeKey.trim();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 파일 경로를 만듭니다.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user