Compare commits
35 Commits
archive/fe
...
feature/mc
| Author | SHA1 | Date | |
|---|---|---|---|
| 6127ffb6ce | |||
| 973c650bed | |||
| dc6f60219b | |||
| 6746c59ab3 | |||
| e8ed554351 | |||
| 5d8d004f93 | |||
| 4e4c341f5d | |||
| ad7fccbed1 | |||
| 58d3014a0f | |||
| 2d730ea9c1 | |||
| 6653030f03 | |||
| 8a6c3617b4 | |||
| 9905d52db3 | |||
| 548c36f97d | |||
| 6526e732f9 | |||
| 2e997b1819 | |||
| c9a6bd2b0a | |||
| fe7d8243f6 | |||
| 5cfb8a1bca | |||
| 3de052a22d | |||
| 60788525d6 | |||
|
|
422dd8c6e7 | ||
| 787489f6c1 | |||
| 0a21031a42 | |||
| 4e45a8f321 | |||
|
|
3e0c251488 | ||
|
|
c41e9f2da4 | ||
|
|
9ddcb99631 | ||
|
|
044c8ef767 | ||
| 7f431a1c37 | |||
|
|
e86942ddd4 | ||
|
|
86e63ced18 | ||
|
|
a86a03ca90 | ||
|
|
ed0958ef43 | ||
|
|
b1286a6cd2 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -20,6 +20,5 @@ gradlew text eol=lf
|
||||
*.jpg binary
|
||||
*.pdf binary
|
||||
*.pptx binary
|
||||
*.xlsx binary
|
||||
*.p12 binary
|
||||
*.jks binary
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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"
|
||||
@@ -1,114 +0,0 @@
|
||||
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
|
||||
11
.gitea/workflows/deploy.yaml
Normal file
11
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
name: Deploy Gateway
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
- name: Run deploy script
|
||||
run: /home/ubuntu/apps/prd-dap-gateway/deploy.sh
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -21,6 +21,10 @@ AGENTS.md
|
||||
# 결정은 docs/decisions/의 ADR에, 규칙은 계약 테스트에 남긴다(AGENTS.md 4절).
|
||||
docs/superpowers/
|
||||
|
||||
# 에이전트 도구가 로컬에 만드는 상태 파일. 개발자마다 달라지므로 공유하지 않는다.
|
||||
.ua/
|
||||
skills-lock.json
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
@@ -38,8 +42,5 @@ secrets/
|
||||
# Claude Code: 공유 설정(.claude/settings.json)은 커밋하고 개인 설정은 제외한다.
|
||||
.claude/settings.local.json
|
||||
|
||||
# 에이전트 도구가 만드는 로컬 캐시·잠금 파일. 저장소 산출물이 아니다.
|
||||
.ua/
|
||||
skills-lock.json
|
||||
|
||||
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,9 +1,16 @@
|
||||
FROM eclipse-temurin:21-jre
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY gradlew .
|
||||
COPY gradle gradle
|
||||
COPY build.gradle settings.gradle ./
|
||||
COPY src src
|
||||
RUN chmod +x gradlew && ./gradlew clean build -x test
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /opt/app
|
||||
COPY build/libs/ax-hub-mcp-server.jar app.jar
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Asia/Seoul
|
||||
COPY --from=builder /app/build/libs/ax-hub-mcp-server.jar app.jar
|
||||
EXPOSE 8080
|
||||
USER 1001
|
||||
|
||||
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/opt/app/app.jar"]
|
||||
|
||||
115
README.md
115
README.md
@@ -1,7 +1,6 @@
|
||||
# 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가 소유한다.
|
||||
|
||||
@@ -25,18 +24,16 @@ 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`는 검사만 하고 고쳐 주지 않는다.
|
||||
|
||||
@@ -55,9 +52,8 @@ $env:MCP_LOCAL_TOOL_REGISTRY_FILE='file:C:/path/local-tools.json'
|
||||
|
||||
## 공개 계약
|
||||
|
||||
- 공개 endpoint: `POST https://{global.mcpHost}/mcp/{routeKey}`
|
||||
- 예: `https://mcp-dev.apps.example.internal/mcp/cus`
|
||||
- route key는 URI에서만 결정된다. route 없는 `/mcp` 호출은 거부한다
|
||||
- 공개 endpoint: `POST https://{global.mcpHost}{deployments.<key>.publicPath}`
|
||||
- 예: `https://mcp-dev.apps.example.internal/mcp/processing-critical`
|
||||
- OpenShift Route는 공개 path로 MCP Service만 선택하고, 컨테이너가 같은 path를 직접 처리
|
||||
- Method: `initialize`, `notifications/initialized`, `tools/list`, `tools/call`
|
||||
- Response: 항상 단일 `application/json` JSON-RPC response
|
||||
@@ -74,9 +70,9 @@ Agent Builder는 공개 URL마다 별도 MCP로 등록하고 initialize한다. U
|
||||
| 환경 | Tool 원천 | Redis |
|
||||
|---|---|---|
|
||||
| `local` | local JSON fixture | 사용 안 함 |
|
||||
| 운영(`ocp`) | Portal registry가 알려 준 route별 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 Portal registry fallback에 사용 |
|
||||
| 운영(`prod`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 |
|
||||
|
||||
요청 경로의 `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)).
|
||||
요청 경로의 `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)).
|
||||
|
||||
Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정에서만 정한다. Agent Builder의 `arguments`와 Tool Service 매니페스트는 호출 대상을 바꿀 수 없다.
|
||||
|
||||
@@ -84,19 +80,24 @@ Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정
|
||||
|
||||
## Correlation과 로그
|
||||
|
||||
호출자가 보내는 헤더는 다섯 개이며 **모두 선택값**이다. 값은 그대로 Tool Service 요청 헤더로 bypass한다.
|
||||
Agent Builder가 보낼 수 있는 표준 헤더는 12개다. MCP는 권한 판정이나 헤더 업무 검증을 하지 않고, 표준 헤더를 필수로 요구하지 않는다. 값이 없으면 없는 상태로 두며, 이 표준 헤더 때문에 요청을 차단하지 않는다.
|
||||
|
||||
| 헤더 | 의미 | 없을 때 |
|
||||
|-----------------------|----------------------------|---------|
|
||||
| `guid` | 요청 하나를 끝까지 따라가는 상관 값(UUID) | 서버가 생성 |
|
||||
| `x-request-id` | 개별 HTTP 요청 ID | 서버가 생성 |
|
||||
| `mcp-session-id` | initialize lifecycle 상관 값 | 전달하지 않음 |
|
||||
| `employee-no` | 사원번호 | 전달하지 않음 |
|
||||
| `virtual-employee-no` | 가상사원번호(상담사 등 비사원) | 전달하지 않음 |
|
||||
| 헤더 | 기대 형식 | MCP 동작 |
|
||||
|---|---|---|
|
||||
| `X-Guid` | UUID V4 | 있으면 end-to-end 상관 값으로 응답과 Tool Service 호출에 이어서 사용하고, 없으면 생략 |
|
||||
| `X-Praf-No` | 숫자 8자리 | 있으면 Tool Service로 전달. 권한 판단은 하지 않음 |
|
||||
| `X-Request-Id` | UUID V4 | 있으면 Agent→MCP 요청 식별자로 사용. Tool Service 호출 시에는 새 UUID V4로 재채번 |
|
||||
| `X-Request-Time` | ISO-8601 offset date-time | 있으면 수신 context에 보관. Tool Service 호출 시에는 재채번 시각으로 교체 |
|
||||
| `X-Vrtl-Praf-No` | 숫자 8자리 | 있으면 Tool Service로 전달 |
|
||||
| `X-App-Code` | 대문자 3자리 | 있으면 Tool Service로 전달 |
|
||||
| `X-Project-Code` | 대문자 5자리 | 있으면 Tool Service로 전달 |
|
||||
| `X-User-Ip` | 사용자 단말 IP | 있으면 Tool Service로 전달 |
|
||||
| `X-Caller-IP` | 호출 서버 IP | 있으면 수신 context에 보관. Tool Service 호출 시 MCP 서버 IP로 교체 |
|
||||
| `X-Caller-Host` | 호출 서버 host name | 있으면 수신 context에 보관. Tool Service 호출 시 MCP 서버 host로 교체 |
|
||||
| `X-Channel` | 채널 코드 | 있으면 수신 context에 보관. Tool Service 호출 시 `MCP`로 교체 |
|
||||
| `X-Agent-Id` | Agent 식별자 | 있으면 Tool Service로 전달 |
|
||||
|
||||
`employee-no`와 `virtual-employee-no`는 **불투명 값**이다. 형식이나 의미를 해석하지 않고, 개행이 섞여 downstream 헤더가 조작되는 것만 막은 뒤 그대로 전달한다.
|
||||
|
||||
MDC는 사용하지 않는다. 로그에는 `guid`와 `x-request-id`만 남기며 **사원 식별자는 기록하지 않는다.** request/response body와 credential도 남기지 않는다.
|
||||
MCP는 사번·가상사번·사용자 IP를 로그에 남기지 않는다. MDC는 사용하지 않으며 로그에는 `X-Guid`와 MCP가 받은 `X-Request-Id`만 남긴다. request/response body와 credential도 기본 로그에 남기지 않는다.
|
||||
|
||||
`Authorization`은 `mcp.tool-client.forward-authorization` 설정이 켜진 경우에만 전달한다. MCP는 이 값을 해석하지 않는다.
|
||||
|
||||
@@ -104,24 +105,20 @@ 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 |
|
||||
| 업무 권한 | Tool Service |
|
||||
| MCP Pod 직접 접근 제한 | 플랫폼 NetworkPolicy |
|
||||
| 사용자 인증과 Tool 실행 권한 | Agent Builder |
|
||||
| 사원 식별자 복호화(KMS)와 업무 권한 | Tool Service |
|
||||
|
||||
⚠️ **Route IP allowlist와 NetworkPolicy는 선택 사항이 아니다.** 외부 요청은 Route가 Agent Builder의 고정 egress CIDR만 받고, backend 요청은 ingress controller 또는 허용된 Agent Builder
|
||||
namespace에서만 MCP Pod에 도달한다. 실제 CIDR을 넣지 않은 배포는 운영에 사용할 수 없다.
|
||||
⚠️ **Route IP allowlist와 NetworkPolicy는 선택 사항이 아니다.** 외부 요청은 Route가 Agent Builder의 고정 egress CIDR만 받고, backend 요청은 ingress controller 또는 허용된 Agent Builder namespace에서만 MCP Pod에 도달한다. 실제 CIDR을 넣지 않은 배포는 운영에 사용할 수 없다. `HelmDeploymentContractTest`가 두 경계가 Chart에서 빠지지 않도록 고정한다.
|
||||
|
||||
## 운영 설정
|
||||
|
||||
**아래 내용은 아직 미정으로 내부 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)
|
||||
@@ -131,31 +128,43 @@ namespace에서만 MCP Pod에 도달한다. 실제 CIDR을 넣지 않은 배포
|
||||
readiness는 첫 Tool discovery 시도가 끝나고 usable in-memory snapshot이 있을 때만 UP이다. 원천 장애 중에도
|
||||
기존 memory 또는 Redis last-good이 있으면 서비스를 유지하고, 아무 성공본도 없으면 트래픽을 받지 않는다.
|
||||
|
||||
**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)).
|
||||
**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을 정한다.
|
||||
|
||||
배포 정의는 [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))이다.
|
||||
배포 정의는 [Helm Chart](deploy/helm/mcp-server/) 하나뿐이다. 배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 설치할 배포 하나는 `--set`으로 고른다.
|
||||
|
||||
```bash
|
||||
helm upgrade --install axhub-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml -n <namespace>
|
||||
helm upgrade --install processing-critical-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n <namespace>
|
||||
```
|
||||
|
||||
배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 두 모드의 차이, 등급별 가용성, 확정 전 임시값은 [deploy/README.md](deploy/README.md)가 정본이다.
|
||||
**MCP Server와 Tool Service는 같은 namespace에 배포한다.** 그래서 values에는 Tool Service의 이름만 적고 주소는 template이 조립한다. 환경마다 URL을 반복해 적지 않으므로 오타로 엉뚱한 곳을 호출할 수 없다.
|
||||
|
||||
평문 manifest가 필요하면 `deploy/ci/render-manifests.sh`가 `helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다.
|
||||
```yaml
|
||||
deployments:
|
||||
processing-critical:
|
||||
name: processing-critical-mcp
|
||||
service: processing-critical-tools # ← 이름만. 주소는 template이 만든다
|
||||
namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다
|
||||
tier: critical
|
||||
publicPath: /mcp/processing-critical # ← 같은 환경 host 안에서 유일
|
||||
```
|
||||
|
||||
빌드·이미지·배포 실행 방식은 원래 사내 표준 CI/CD가 담당한다. GitOps 저장소가 준비되기 전까지만 `.gitea/workflows/`가 임시로 그 역할을 하며, 그 방식이 무엇을 포기하는지와 넘길 때 할 일은 [deploy/README.md](deploy/README.md#gitops-저장소가-없는-동안의-우회)에 적었다. 배포 시 알아야 할 앱 제약도 같은 문서에 정리했다.
|
||||
배포가 10개든 20개든 파일 수는 늘지 않는다. 자세한 사용법은 [deploy/README.md](deploy/README.md)에 있다.
|
||||
|
||||
평문 manifest가 필요하면 `helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다.
|
||||
|
||||
빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며 이 저장소가 정하지 않는다. 배포 시 알아야 할 앱 제약은 [deploy/README.md](deploy/README.md)에 정리했다.
|
||||
|
||||
## 문서 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 계약 |
|
||||
| [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) | 저장소 작업 규칙과 공개 정책 |
|
||||
| 문서 | 책임 |
|
||||
|---|---|
|
||||
| [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) | 저장소 작업 규칙과 공개 정책 |
|
||||
|
||||
Superseded/Rejected 문서는 이력일 뿐 현재 구현 근거가 아니다. 코드나 공개 계약을 변경할 때는 가까운 테스트와 해당 현재 계약을 함께 수정한다. 변경을 마치기 전에 실행할 검증 명령은 [AGENTS.md](AGENTS.md)의 완료 기준이 정본이다.
|
||||
|
||||
|
||||
@@ -69,16 +69,12 @@ tasks.named('check') {
|
||||
dependsOn 'ideaFormatCheck'
|
||||
}
|
||||
|
||||
group = 'io.shinhanlife.dap.biz.mcp'
|
||||
group = 'io.shinhanlife.dat.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
config/local-tool-responses-sample-v1.json
Normal file
28
config/local-tool-responses-sample-v1.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"routes": {
|
||||
"cus": {
|
||||
"tools": {
|
||||
"crm_activity_status": { "httpStatus": 200, "body": { "source": "local-fixture", "customerId": "CUST001", "status": "ACTIVE", "lastActivityDate": "2026-09-08" } },
|
||||
"crm_customer_detail": { "httpStatus": 200, "body": { "source": "local-fixture", "customerId": "CUST001", "customerName": "테스트 고객", "grade": "VIP", "status": "정상" } }
|
||||
}
|
||||
},
|
||||
"sal": {
|
||||
"tools": {
|
||||
"cmm_claim_search": { "httpStatus": 200, "body": { "source": "local-fixture", "claimNo": "CLM202609080001", "contractNo": "10023456789", "claimStatus": "심사중" } },
|
||||
"cmm_memo_retriever": { "httpStatus": 200, "body": { "source": "local-fixture", "memoStatus": "OPEN", "totalCount": 1, "items": [{ "memoId": "MEMO001", "title": "테스트 의뢰서" }] } }
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"tools": {
|
||||
"pro_individual_inquiry": { "httpStatus": 200, "body": { "source": "local-fixture", "customerId": "12345", "name": "김테스트", "phoneNumber": "010-0000-0000", "status": "정상" } },
|
||||
"pro_recruitment_data": { "httpStatus": 200, "body": { "source": "local-fixture", "companyCode": "005930", "year": "2026", "quarter": "3", "commissionRate": 12.5 } }
|
||||
}
|
||||
},
|
||||
"sys": {
|
||||
"tools": {
|
||||
"iam_access_policy": { "httpStatus": 200, "body": { "source": "local-fixture", "environment": "dev", "policyName": "DEV_DEFAULT", "status": "ACTIVE" } },
|
||||
"iam_access_token_status": { "httpStatus": 200, "body": { "source": "local-fixture", "environment": "dev", "tokenStatus": "VALID", "expiresInSeconds": 3600 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
config/local-toolserver-info-sample-v1.json
Normal file
54
config/local-toolserver-info-sample-v1.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"registryRevision": "local-toolserver-info-sample-v1",
|
||||
"routes": [
|
||||
{
|
||||
"routeKey": "cus",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "was-cus",
|
||||
"displayName": "CUS Tool Server",
|
||||
"serviceDomain": "https://tool-cus.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"routeKey": "sal",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "was-sal",
|
||||
"displayName": "SAL Tool Server",
|
||||
"serviceDomain": "https://tool-sal.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeKey": "pro",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "was-pro",
|
||||
"displayName": "PRO Tool Server",
|
||||
"serviceDomain": "https://tool-pro.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeKey": "sys",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "was-sys",
|
||||
"displayName": "SYS Tool Server",
|
||||
"serviceDomain": "https://tool-sys.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
38
config/manifests/was-cus-manifest-sample-v1.json
Normal file
38
config/manifests/was-cus-manifest-sample-v1.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"bundleId": "was-cus",
|
||||
"revision": "local-cus-manifest-v1",
|
||||
"tools": [
|
||||
{
|
||||
"name": "crm_activity_status",
|
||||
"endpoint": "/mcp/crm_activity_status",
|
||||
"title": "고객 활동 현황 조회",
|
||||
"description": "고객 활동 현황을 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customerId": { "type": "string", "description": "고객 식별자" },
|
||||
"startDate": { "type": "string", "description": "조회 시작일(YYYY-MM-DD)" },
|
||||
"endDate": { "type": "string", "description": "조회 종료일(YYYY-MM-DD)" }
|
||||
},
|
||||
"required": ["customerId"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "고객 활동 현황 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
},
|
||||
{
|
||||
"name": "crm_customer_detail",
|
||||
"endpoint": "/mcp/crm_customer_detail",
|
||||
"title": "고객 상세 정보 조회",
|
||||
"description": "고객 상세 정보를 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "customerId": { "type": "string", "description": "고객 식별자" } },
|
||||
"required": ["customerId"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "고객 상세 정보 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
40
config/manifests/was-pro-manifest-sample-v1.json
Normal file
40
config/manifests/was-pro-manifest-sample-v1.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"bundleId": "was-pro",
|
||||
"revision": "local-pro-manifest-v1",
|
||||
"tools": [
|
||||
{
|
||||
"name": "pro_individual_inquiry",
|
||||
"endpoint": "/mcp/pro_individual_inquiry",
|
||||
"title": "개인고객 상세조회",
|
||||
"description": "개인고객 상세 정보를 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customerId": { "type": "string", "pattern": "^[A-Za-z0-9]{1,20}$", "description": "고객 식별자" },
|
||||
"name": { "type": "string", "description": "고객명" }
|
||||
},
|
||||
"required": ["customerId"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "개인고객 상세조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
},
|
||||
{
|
||||
"name": "pro_recruitment_data",
|
||||
"endpoint": "/mcp/pro_recruitment_data",
|
||||
"title": "모집수수료 공시 조회",
|
||||
"description": "모집수수료 공시 정보를 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"companyCode": { "type": "string", "pattern": "^[0-9]{6}$", "description": "회사 코드" },
|
||||
"year": { "type": "string", "pattern": "^[0-9]{4}$", "description": "조회 연도" }
|
||||
},
|
||||
"required": ["companyCode", "year"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "모집수수료 공시 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
40
config/manifests/was-sal-manifest-sample-v1.json
Normal file
40
config/manifests/was-sal-manifest-sample-v1.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"bundleId": "was-sal",
|
||||
"revision": "local-sal-manifest-v1",
|
||||
"tools": [
|
||||
{
|
||||
"name": "cmm_claim_search",
|
||||
"endpoint": "/mcp/cmm_claim_search",
|
||||
"title": "보험금 청구 상태 조회",
|
||||
"description": "청구번호로 보험금 청구 상태를 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claimNo": { "type": "string", "description": "청구번호" },
|
||||
"contractNo": { "type": "string", "description": "계약번호" }
|
||||
},
|
||||
"required": ["claimNo"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "보험금 청구 상태 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
},
|
||||
{
|
||||
"name": "cmm_memo_retriever",
|
||||
"endpoint": "/mcp/cmm_memo_retriever",
|
||||
"title": "의뢰서 목록 조회",
|
||||
"description": "의뢰서 목록을 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memoStatus": { "type": "string", "description": "의뢰서 상태" },
|
||||
"searchKeyword": { "type": "string", "description": "검색어" }
|
||||
},
|
||||
"required": ["memoStatus", "searchKeyword"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "의뢰서 목록 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
34
config/manifests/was-sys-manifest-sample-v1.json
Normal file
34
config/manifests/was-sys-manifest-sample-v1.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"bundleId": "was-sys",
|
||||
"revision": "local-sys-manifest-v1",
|
||||
"tools": [
|
||||
{
|
||||
"name": "iam_access_policy",
|
||||
"endpoint": "/mcp/iam_access_policy",
|
||||
"title": "접근 정책 조회",
|
||||
"description": "환경별 접근 정책을 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "environment": { "type": "string", "description": "조회할 환경명" } },
|
||||
"required": ["environment"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "접근 정책 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
},
|
||||
{
|
||||
"name": "iam_access_token_status",
|
||||
"endpoint": "/mcp/iam_access_token_status",
|
||||
"title": "접근 토큰 상태 조회",
|
||||
"description": "환경별 접근 토큰 상태를 가짜 데이터로 조회합니다.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "environment": { "type": "string", "description": "조회할 환경명" } },
|
||||
"required": ["environment"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "title": "접근 토큰 상태 조회", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
|
||||
"_meta": { "version": "1.0.0", "timeoutMillis": 5000, "enabled": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
186
deploy/README.md
186
deploy/README.md
@@ -1,80 +1,68 @@
|
||||
# 배포 정의
|
||||
|
||||
이 디렉터리는 **배포될 대상**과, 그것을 클러스터에 올리는 **임시 경로**를 정의한다.
|
||||
빌드·배포 실행 방식의 정본은 원래 사내 표준 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가 하나로 고정된다.
|
||||
이 디렉터리는 **배포될 대상**을 정의한다. 빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며
|
||||
이 저장소가 정하지 않는다.
|
||||
|
||||
## Helm Chart
|
||||
|
||||
[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는 두 축으로 나뉜다
|
||||
[helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다. values는 두 축으로 나뉜다.
|
||||
|
||||
| 파일 | 소유하는 것 |
|
||||
|---|---|
|
||||
| `values.yaml` | **배포 토폴로지.** mode, portal 배포 정의, bundles 배포 목록, 등급 기준 |
|
||||
| `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 공개 host·허용 CIDR, Portal registry 주소, 등급별 replica·PDB, 리소스 |
|
||||
| `values.yaml` | **배포 토폴로지.** 어떤 MCP가 어떤 Tool Service를 보는가, 공개 path, 가용성 등급 |
|
||||
| `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 이미지, 공개 host·허용 CIDR, 등급별 replica·PDB, 리소스 |
|
||||
|
||||
환경 파일은 토폴로지를 갖지 않는다. `HelmDeploymentContractTest`가 그 경계를 고정한다.
|
||||
설치할 때 두 번째 축을 `-f`로, 첫 번째 축에서 고를 배포 하나를 `--set deploymentKey=`로 지정한다.
|
||||
|
||||
```bash
|
||||
# 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>
|
||||
helm upgrade --install processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n <namespace>
|
||||
```
|
||||
|
||||
`deploymentKey`에는 기본값이 없다. `bundles`에서 지정을 빠뜨리면 렌더링 단계에서 멈춘다.
|
||||
엉뚱한 배포가 조용히 설치되는 것보다 낫다. 반대로 `portal`에서 `deploymentKey`를 주면 역시 멈춘다.
|
||||
route를 배포 정의에 적기 시작하면 Portal을 원천으로 둔 이유가 사라지기 때문이다.
|
||||
`deploymentKey`에는 기본값이 없다. 지정을 빠뜨리면 렌더링 단계에서 멈춘다.
|
||||
엉뚱한 배포가 조용히 설치되는 것보다 낫다.
|
||||
|
||||
### 공개 host와 path
|
||||
### 공유 host와 배포별 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가 배포별로 다르다.
|
||||
[ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md)에 따라 한 환경은 하나의 공개 host를 사용하고,
|
||||
각 Helm release는 고유 path의 OpenShift Route를 만든다. Route는 Service만 선택하고 공개 path를 그대로
|
||||
전달하며, 컨테이너가 같은 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한다.
|
||||
공개 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와 공유 정책은 아직 확정되지 않았다.
|
||||
|
||||
### 가용성 등급
|
||||
|
||||
배포를 업무 × 중요도로 나누는 목적은 **중요 등급에만 비용을 쓰기 위해서**다.
|
||||
|
||||
```yaml
|
||||
tiers:
|
||||
critical: { replicas: 3, podDisruptionBudget: true, spreadAcrossNodes: true }
|
||||
@@ -83,74 +71,27 @@ tiers:
|
||||
|
||||
test와 prod의 `critical`은 **replica 2 이상, PodDisruptionBudget, 노드 분산 설정이 필수**다. replica가
|
||||
1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내릴 수 있다.
|
||||
dev는 배포마다 Pod 1개로 운영하므로 이 검사 대상이 아니다.
|
||||
`HelmDeploymentContractTest`는 values와 template의 정적 규칙을 검사한다. dev는 배포마다 Pod 1개로
|
||||
운영하므로 이 검사 대상이 아니다.
|
||||
|
||||
**`portal` 모드에서 등급별 물리 분리는 성립하지 않는다.** 배포가 하나이므로 전 route가 같은
|
||||
프로세스·같은 replica set을 공유한다([ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) 전제 2).
|
||||
`tiers`는 그 하나의 배포에 어떤 가용성 기준을 적용할지만 정한다.
|
||||
|
||||
### 렌더링 검증
|
||||
|
||||
`HelmDeploymentContractTest`는 values와 template의 **정적 규칙**만 본다. helper 오류, 조건 분기 실수,
|
||||
들여쓰기는 실제로 렌더링해야 드러난다. 두 검사는 서로를 대신하지 못한다.
|
||||
정적 테스트는 Helm 렌더러를 실행하지 않는다. 실제 배포 파이프라인은 사용하는 환경과 등급별로
|
||||
`helm lint`와 `helm template`을 실행해 병합된 values와 생성 YAML을 확인해야 한다.
|
||||
|
||||
```bash
|
||||
deploy/ci/render-manifests.sh # 환경 × 모드 전 조합 lint + template
|
||||
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
|
||||
```
|
||||
|
||||
CI가 매 push에서 같은 스크립트를 돌리고 결과를 `rendered-manifests` 아티팩트로 올린다.
|
||||
**나누는 것만으로 가용성이 생기지는 않는다.** 같은 노드 배치, namespace 쿼터, 공통 Redis·클러스터
|
||||
장애는 분할로 막히지 않는다. 남은 작업은 [extension-points.md](../docs/extension-points.md)의
|
||||
"운영 적용 전 필수 보완"에서 관리한다.
|
||||
|
||||
## 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에 연결되지 않을 때
|
||||
### dev에서 MCP에 연결되지 않을 때
|
||||
|
||||
**먼저 Tool Service가 떠 있는지 확인한다.** readiness가 usable snapshot을 요구하므로, Tool Service가
|
||||
없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다.
|
||||
"MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다.
|
||||
|
||||
`portal` 모드에서는 Portal registry 조회부터 확인한다. registry를 못 읽으면 route 자체가 등록되지 않아
|
||||
`/mcp/{routeKey}` 호출이 route key 검증에서 거부된다.
|
||||
없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다. dev는 배포마다 Pod 1개라
|
||||
그 순간 그 MCP로는 아예 연결되지 않는다. "MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다.
|
||||
|
||||
```bash
|
||||
kubectl get pod -l app=<배포 이름> # 0/1 Ready이면 이 경우다
|
||||
@@ -158,15 +99,14 @@ kubectl describe pod <pod> # Readiness probe 실패 사유
|
||||
kubectl port-forward <pod> 9090:9090 # /actuator/toolBundles로 bundle 상태 확인
|
||||
```
|
||||
|
||||
Tool Service가 뜨면 다음 refresh 주기 안에 스스로 Ready가 된다. 재기동할 필요가 없다.
|
||||
Tool Service가 뜨면 다음 refresh 주기(기본 30초) 안에 스스로 Ready가 된다. 재기동할 필요가 없다.
|
||||
`/actuator/toolBundles`는 management 포트라 NetworkPolicy가 관제 namespace로 제한하므로,
|
||||
개발자는 위처럼 `port-forward`로 본다.
|
||||
|
||||
## 확정 전 임시값
|
||||
|
||||
`values.yaml`의 이미지 경로와 Tool Service 이름, `values-{env}.yaml`의 namespace·공개 host·Route 허용
|
||||
CIDR·Portal registry 주소는 자리표시자다. 각 파일의 `TODO` 주석을 참고해 확정 시 교체하고,
|
||||
존재하지 않는 배포는 `deployments`에서 삭제한다.
|
||||
`values.yaml`의 Tool Service 이름·이미지 경로와 `values-{env}.yaml`의 namespace·공개 host·Route 허용 CIDR은 자리표시자다.
|
||||
각 파일의 `TODO` 주석을 참고해 확정 시 교체하고, 존재하지 않는 배포는 `deployments`에서 삭제한다.
|
||||
|
||||
## 배포 시 알아야 할 앱 제약
|
||||
|
||||
@@ -179,14 +119,14 @@ CIDR·Portal registry 주소는 자리표시자다. 각 파일의 `TODO` 주석
|
||||
| `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만 업무 포트에 허용한다.
|
||||
|
||||
`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#운영-적용-전-필수-보완)에서 관리한다.
|
||||
## 미확정 항목
|
||||
|
||||
배포 정의를 이 저장소가 어디까지 소유하는지, namespace·registry 명명 규칙은 아직 확정되지 않았다.
|
||||
[docs/extension-points.md](../docs/extension-points.md)에서 관리한다.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/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"
|
||||
32
deploy/docker-compose.yml
Normal file
32
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
gateway:
|
||||
build:
|
||||
context: ./dap-was-dapms
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "127.0.0.1:9281:8080"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
# ====================================================================================
|
||||
# [AX Portal Mock 연동 설정]
|
||||
# 현재 AX Portal API 서버가 실제 구동되어 있지 않으므로, Gateway가 엔드포인트를
|
||||
# 동적으로 받아올 수 있도록 로컬 가짜 포털(portal-mock)을 띄워 라우팅 주소를 제공합니다.
|
||||
# 향후 실제 AX Portal이 배포되면 아래 주소를 진짜 포털 주소로 변경하시기 바랍니다.
|
||||
# 예: - MCP_PORTAL_REGISTRY_URL=https://axhub.devjun.net/api/portal/registry
|
||||
# ====================================================================================
|
||||
- MCP_PORTAL_REGISTRY_URL=http://portal-mock/registry.json
|
||||
restart: always
|
||||
networks:
|
||||
- prd-dap-net
|
||||
|
||||
portal-mock:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./portal-registry.json:/usr/share/nginx/html/registry.json:ro
|
||||
restart: always
|
||||
networks:
|
||||
- prd-dap-net
|
||||
|
||||
networks:
|
||||
prd-dap-net:
|
||||
external: true
|
||||
@@ -1,277 +0,0 @@
|
||||
# AX HUB MCP 서버 개발계 수동 배포 샘플
|
||||
#
|
||||
# 주의:
|
||||
# - 이 파일은 Helm template이 아니라, AA와 값을 협의한 뒤 수동으로 적용할 Raw OpenShift YAML 샘플이다.
|
||||
# - "{{대문자_이름}}"은 확정되지 않은 값이다. 모든 자리표시자를 실제 값으로 교체한 뒤 적용한다.
|
||||
# - 비밀번호와 API Key를 담는 Secret 및 그 참조는 현재 사용하지 않으므로 포함하지 않았다.
|
||||
# - Harbor 인증이 필요하면 AA가 별도로 ServiceAccount에 image pull secret을 연결해야 한다.
|
||||
# - 이 파일은 임시 수동 배포용 예제이며, 배포 정의의 정본은 deploy/helm/mcp-server Chart다.
|
||||
#
|
||||
# AA와 협의할 값:
|
||||
# - {{DEV_NAMESPACE}}: MCP 서버를 배포할 개발계 namespace
|
||||
# - {{HARBOR_IMAGE_REPOSITORY}}: Harbor project를 포함한 이미지 경로. 예: harbor.example/axhub/axhub-mcp
|
||||
# - {{IMAGE_TAG}}: AA가 Podman으로 만들어 Push한 이미지 tag
|
||||
# - {{DEV_MCP_HOST}}: 개발계 OpenShift Route host
|
||||
# - {{AGENT_BUILDER_EGRESS_CIDR}}: Route 접근을 허용할 Agent Builder의 고정 egress CIDR
|
||||
# - {{AGENT_BUILDER_NAMESPACE}}: Agent Builder Pod이 있는 namespace
|
||||
# - {{DEV_PORTAL_REGISTRY_URL}}: 개발계 Portal registry API 주소
|
||||
# - {{REDIS_SERVICE_HOST}}: 개발계 Redis Service host 또는 FQDN
|
||||
# - {{CONFIG_VERSION}}: ConfigMap을 바꿀 때마다 증가시키는 값. 예: 1, 2, 3
|
||||
#
|
||||
# 적용 전 자리표시자 확인 예시(PowerShell):
|
||||
# Get-Content .\deploy\examples\axhub-mcp-dev-manual.template.yaml |
|
||||
# Where-Object { $_ -notmatch '^\s*#' } |
|
||||
# Select-String -Pattern '\{\{[A-Z0-9_]+\}\}'
|
||||
#
|
||||
# 적용 예시:
|
||||
# oc apply -f .\deploy\examples\axhub-mcp-dev-manual.template.yaml
|
||||
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: axhub-mcp-config
|
||||
namespace: "{{DEV_NAMESPACE}}"
|
||||
labels:
|
||||
app: axhub-mcp
|
||||
app.kubernetes.io/name: axhub-mcp
|
||||
app.kubernetes.io/instance: axhub-mcp-dev
|
||||
app.kubernetes.io/component: mcp-server
|
||||
app.kubernetes.io/part-of: ax-hub
|
||||
ax-hub/mode: portal
|
||||
ax-hub/tier: critical
|
||||
data:
|
||||
# SPRING_PROFILES_ACTIVE=dev이므로 파일명도 application-dev.yml이어야 한다.
|
||||
application-dev.yml: |
|
||||
management:
|
||||
server:
|
||||
port: 9090
|
||||
health:
|
||||
redis:
|
||||
enabled: false
|
||||
|
||||
mcp:
|
||||
# 환경별 Redis key가 서로 겹치지 않도록 개발계 identity를 고정한다.
|
||||
identity: axhub-mcp-dev
|
||||
|
||||
# Route가 경로를 변경하지 않고 그대로 전달하므로 Route path와 같아야 한다.
|
||||
endpoint-path: "/mcp"
|
||||
|
||||
registry:
|
||||
refresh-interval-seconds: 30
|
||||
refresh-jitter-seconds: 5
|
||||
|
||||
discovery:
|
||||
enabled: true
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
# Portal 조회 실패 시 사용하는 Redis fallback key다. Portal과 같은 key인지 확인한다.
|
||||
portal-registry-key: "axhub:mcp:portal-registry"
|
||||
|
||||
portal:
|
||||
enabled: true
|
||||
registry-url: "{{DEV_PORTAL_REGISTRY_URL}}"
|
||||
refresh-interval-seconds: 60
|
||||
|
||||
# Portal이 route와 Tool Server 주소를 제공하므로 정적 bundle은 두지 않는다.
|
||||
bundles: []
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: axhub-mcp
|
||||
namespace: "{{DEV_NAMESPACE}}"
|
||||
labels:
|
||||
app: axhub-mcp
|
||||
app.kubernetes.io/name: axhub-mcp
|
||||
app.kubernetes.io/instance: axhub-mcp-dev
|
||||
app.kubernetes.io/component: mcp-server
|
||||
app.kubernetes.io/part-of: ax-hub
|
||||
ax-hub/mode: portal
|
||||
ax-hub/tier: critical
|
||||
spec:
|
||||
# 개발계 임시 테스트이므로 Pod 한 개로 구성한다.
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: axhub-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: axhub-mcp
|
||||
app.kubernetes.io/name: axhub-mcp
|
||||
app.kubernetes.io/instance: axhub-mcp-dev
|
||||
app.kubernetes.io/component: mcp-server
|
||||
app.kubernetes.io/part-of: ax-hub
|
||||
ax-hub/mode: portal
|
||||
ax-hub/tier: critical
|
||||
annotations:
|
||||
# Raw YAML은 Helm checksum을 자동 생성하지 못한다. ConfigMap 변경 시 이 값을 올리면 Pod이 재기동된다.
|
||||
ax-hub/config-version: "{{CONFIG_VERSION}}"
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 45
|
||||
containers:
|
||||
- name: mcp-server
|
||||
image: "{{HARBOR_IMAGE_REPOSITORY}}:{{IMAGE_TAG}}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: management
|
||||
containerPort: 9090
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: SPRING_PROFILES_ACTIVE
|
||||
value: dev
|
||||
# ConfigMap의 application-dev.yml을 JAR 내부 설정보다 우선 적용한다.
|
||||
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
|
||||
value: file:/opt/app/config/
|
||||
- name: REDIS_HOST
|
||||
value: "{{REDIS_SERVICE_HOST}}"
|
||||
- name: REDIS_PORT
|
||||
value: "16379"
|
||||
- name: MANAGEMENT_SERVER_PORT
|
||||
value: "9090"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /opt/app/config
|
||||
readOnly: true
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: management
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: management
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: axhub-mcp-config
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: axhub-mcp
|
||||
namespace: "{{DEV_NAMESPACE}}"
|
||||
labels:
|
||||
app: axhub-mcp
|
||||
app.kubernetes.io/name: axhub-mcp
|
||||
app.kubernetes.io/instance: axhub-mcp-dev
|
||||
app.kubernetes.io/component: mcp-server
|
||||
app.kubernetes.io/part-of: ax-hub
|
||||
ax-hub/mode: portal
|
||||
ax-hub/tier: critical
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: axhub-mcp
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
apiVersion: route.openshift.io/v1
|
||||
kind: Route
|
||||
metadata:
|
||||
name: axhub-mcp
|
||||
namespace: "{{DEV_NAMESPACE}}"
|
||||
labels:
|
||||
app: axhub-mcp
|
||||
app.kubernetes.io/name: axhub-mcp
|
||||
app.kubernetes.io/instance: axhub-mcp-dev
|
||||
app.kubernetes.io/component: mcp-server
|
||||
app.kubernetes.io/part-of: ax-hub
|
||||
ax-hub/mode: portal
|
||||
ax-hub/tier: critical
|
||||
annotations:
|
||||
haproxy.router.openshift.io/timeout: 300s
|
||||
# 이 서버는 자체 인증을 하지 않으므로 반드시 실제 Agent Builder 고정 egress CIDR로 제한한다.
|
||||
haproxy.router.openshift.io/ip_allowlist: "{{AGENT_BUILDER_EGRESS_CIDR}}"
|
||||
spec:
|
||||
host: "{{DEV_MCP_HOST}}"
|
||||
# /mcp/{routeKey} 요청도 prefix match로 이 Route에 들어온다. rewrite는 사용하지 않는다.
|
||||
path: /mcp
|
||||
to:
|
||||
kind: Service
|
||||
name: axhub-mcp
|
||||
weight: 100
|
||||
port:
|
||||
targetPort: http
|
||||
tls:
|
||||
termination: edge
|
||||
insecureEdgeTerminationPolicy: Redirect
|
||||
wildcardPolicy: None
|
||||
|
||||
---
|
||||
# MCP 서버는 자체 인증·인가를 하지 않으므로 NetworkPolicy를 제거하면 안 된다.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: axhub-mcp-ingress
|
||||
namespace: "{{DEV_NAMESPACE}}"
|
||||
labels:
|
||||
app: axhub-mcp
|
||||
app.kubernetes.io/name: axhub-mcp
|
||||
app.kubernetes.io/instance: axhub-mcp-dev
|
||||
app.kubernetes.io/component: mcp-server
|
||||
app.kubernetes.io/part-of: ax-hub
|
||||
ax-hub/mode: portal
|
||||
ax-hub/tier: critical
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: axhub-mcp
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
# OpenShift Route를 통과한 요청을 8080 포트로 허용한다.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
policy-group.network.openshift.io/ingress: ""
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
|
||||
# 같은 클러스터 안에서 Agent Builder가 직접 호출하는 경우만 8080 포트로 허용한다.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: "{{AGENT_BUILDER_NAMESPACE}}"
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
|
||||
# Actuator management 포트는 OpenShift 관제 namespace에서만 접근하도록 제한한다.
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: openshift-monitoring
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9090
|
||||
Binary file not shown.
@@ -4,7 +4,6 @@ description: AX HUB MCP Server - Agent Builder와 Tool Service 사이의 statele
|
||||
type: application
|
||||
|
||||
# Chart 자체의 버전. 애플리케이션 버전과 따로 올린다.
|
||||
# 0.2.0에서 배포 모델이 두 가지(portal·bundles)가 되어 values 구조가 바뀌었다.
|
||||
version: 0.2.0
|
||||
version: 0.1.0
|
||||
# 기본 이미지 tag. 배포 시 values의 image.tag가 덮어쓴다.
|
||||
appVersion: "0.1.0"
|
||||
|
||||
@@ -2,27 +2,8 @@
|
||||
설치 대상이 실제로 존재하는지 확인하고, 없으면 읽을 수 있는 메시지로 멈춘다.
|
||||
검사를 하지 않으면 오타가 "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 -}}
|
||||
@@ -31,42 +12,20 @@ deploymentKey가 없고 registryUrl이 필수다. bundles 모드는 그 반대
|
||||
{{- if not (index .Values.tiers $deployment.tier) -}}
|
||||
{{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다. deployments의 tier와 tiers의 key가 어긋났다." $deployment.tier) -}}
|
||||
{{- end -}}
|
||||
{{- 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) -}}
|
||||
{{- 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 -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
설치할 배포 하나를 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 배포가 들어갈 수 있으므로 배포마다 다른 이름을 쓴다.
|
||||
리소스 이름. 하나의 namespace에 여러 MCP 배포가 들어가므로 배포마다 다른 이름을 쓴다.
|
||||
*/}}
|
||||
{{- define "mcp-server.name" -}}
|
||||
{{- include "mcp-server.validate" . -}}
|
||||
{{- (include "mcp-server.selectedDeployment" . | fromYaml).name -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
선택된 배포의 가용성 등급 이름.
|
||||
*/}}
|
||||
{{- define "mcp-server.tier" -}}
|
||||
{{- (include "mcp-server.selectedDeployment" . | fromYaml).tier -}}
|
||||
{{- (index .Values.deployments .Values.deploymentKey).name -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
@@ -79,12 +38,12 @@ Redis key namespace가 되는 식별자.
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
이 MCP가 보는 Tool Service의 host:port. bundles 모드에서만 쓴다.
|
||||
이 MCP가 보는 Tool Service의 host:port.
|
||||
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 -}}
|
||||
@@ -95,8 +54,7 @@ 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/mode: {{ .Values.mode }}
|
||||
ax-hub/tier: {{ include "mcp-server.tier" . }}
|
||||
ax-hub/tier: {{ (index .Values.deployments .Values.deploymentKey).tier }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "mcp-server.selectorLabels" -}}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 배포별로 달라지는 설정만 담는다.
|
||||
# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고,
|
||||
# 이 파일이 같은 이름으로 덮어써 identity와 Tool 원천만 배포 시점에 결정한다.
|
||||
# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-prod.yml이 소유하고,
|
||||
# 이 파일이 같은 이름으로 덮어써 identity와 bundle만 배포 시점에 결정한다.
|
||||
{{- include "mcp-server.validate" . }}
|
||||
{{- $deployment := include "mcp-server.selectedDeployment" . | fromYaml }}
|
||||
{{- $deployment := index .Values.deployments .Values.deploymentKey }}
|
||||
{{- $toolServiceHost := include "mcp-server.toolServiceHost" . }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
@@ -10,7 +11,7 @@ metadata:
|
||||
labels:
|
||||
{{- include "mcp-server.labels" . | nindent 4 }}
|
||||
data:
|
||||
application-ocp.yml: |
|
||||
application-prod.yml: |
|
||||
mcp:
|
||||
# "{배포 이름}-{global.env}"로 조립된다. 환경끼리 Redis key가 겹치지 않는다.
|
||||
identity: {{ include "mcp-server.identity" . }}
|
||||
@@ -18,41 +19,18 @@ data:
|
||||
endpoint-path: {{ $deployment.publicPath | quote }}
|
||||
|
||||
registry:
|
||||
refresh-interval-seconds: {{ .Values.mcp.refreshIntervalSeconds }}
|
||||
refresh-jitter-seconds: {{ .Values.mcp.refreshJitterSeconds }}
|
||||
refreshTtlSeconds: {{ .Values.mcp.refreshTtlSeconds }}
|
||||
|
||||
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://{{ include "mcp-server.toolServiceHost" . }}{{ .Values.toolService.manifestPath }}
|
||||
baseEndpoint: http://{{ include "mcp-server.toolServiceHost" . }}{{ .Values.toolService.basePath }}
|
||||
manifestUrl: http://{{ $toolServiceHost }}{{ .Values.toolService.manifestPath }}
|
||||
baseEndpoint: http://{{ $toolServiceHost }}{{ .Values.toolService.basePath }}
|
||||
enabled: true
|
||||
{{- end }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{- include "mcp-server.validate" . }}
|
||||
{{- $tier := index .Values.tiers (include "mcp-server.tier" .) }}
|
||||
{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -17,17 +17,12 @@ spec:
|
||||
labels:
|
||||
{{- include "mcp-server.labels" . | nindent 8 }}
|
||||
annotations:
|
||||
# ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 설정을 고쳐도
|
||||
# ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 bundle 설정을 고쳐도
|
||||
# 기존 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:
|
||||
@@ -52,7 +47,7 @@ spec:
|
||||
containerPort: {{ .Values.ports.management }}
|
||||
env:
|
||||
- name: SPRING_PROFILES_ACTIVE
|
||||
value: ocp
|
||||
value: prod
|
||||
# ConfigMap을 jar 안의 설정보다 우선 적용한다.
|
||||
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
|
||||
value: file:/opt/app/config/
|
||||
@@ -62,20 +57,10 @@ 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 (include "mcp-server.tier" .) }}
|
||||
{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }}
|
||||
{{- if $tier.podDisruptionBudget }}
|
||||
# 중요 등급 배포가 자발적 중단(노드 drain, 클러스터 업그레이드) 중에도 최소 1개를 남기게 한다.
|
||||
#
|
||||
# replica를 2 이상으로 올려도 PDB가 없으면 노드 drain이 두 Pod을 한꺼번에 내릴 수 있다.
|
||||
# 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다.
|
||||
# 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다(ADR-0007).
|
||||
#
|
||||
# NetworkPolicy와 달리 조건이 붙는다. 저쪽은 인가의 전제라 끌 수 없지만 이것은 가용성 정책이고,
|
||||
# replica 1인 dev에서는 PDB가 오히려 노드 drain을 영구히 막는다.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
{{- include "mcp-server.validate" . }}
|
||||
{{- $deployment := include "mcp-server.selectedDeployment" . | fromYaml }}
|
||||
# OpenShift Route의 path는 prefix 매칭이다. portal 모드에서 path가 "/mcp"이면
|
||||
# /mcp/{routeKey} 전체가 이 Route 하나로 들어오고, route 구분은 컨테이너가 한다(ADR-0013).
|
||||
{{- $deployment := index .Values.deployments .Values.deploymentKey }}
|
||||
apiVersion: route.openshift.io/v1
|
||||
kind: Route
|
||||
metadata:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# dev 환경. 배포마다 Pod 1개로 구성한다.
|
||||
#
|
||||
# dev에서는 중요 등급도 replica 1이다. rolling update 중 수십 초 공백이 생기지만
|
||||
# dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 test·prod에서만 강제하며
|
||||
# dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 prod에서만 강제하며
|
||||
# HelmDeploymentContractTest가 그 사실을 고정한다.
|
||||
#
|
||||
# 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
|
||||
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다.
|
||||
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
|
||||
|
||||
global:
|
||||
@@ -12,11 +12,6 @@ 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,14 +1,16 @@
|
||||
# prod 환경.
|
||||
#
|
||||
# replica는 이 배포가 받는 트래픽 기준으로 잡는다.
|
||||
# 매니페스트 조회 부하 = replica 수 × (route에 붙은 Tool Service 수) / 주기다.
|
||||
# portal 모드는 한 배포가 전 route를 서비스하므로 route가 늘면 이 값이 함께 는다(ADR-0013 전제 3).
|
||||
# replica는 배포 하나가 받는 트래픽 기준으로 잡는다. 업무 × 등급으로 나뉘어 있으므로
|
||||
# 배포 하나가 받는 몫은 전체를 하나로 묶었을 때의 일부다. 등급별 기준은 아래가 정본이다.
|
||||
#
|
||||
# Tool Service 매니페스트는 scheduler가 아니라 요청 시점 TTL 만료 시에만 다시 확인한다.
|
||||
# 요청이 없는 동안에는 매니페스트 조회 부하가 발생하지 않는다.
|
||||
#
|
||||
# 중요 등급은 replica 2 이상과 PodDisruptionBudget이 필수다.
|
||||
# 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내린다.
|
||||
# HelmDeploymentContractTest가 replica·PDB·노드 분산 values를 정적으로 검사한다.
|
||||
#
|
||||
# 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
|
||||
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다.
|
||||
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
|
||||
|
||||
global:
|
||||
@@ -16,11 +18,6 @@ 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 배포 때 처음 겪게 된다.
|
||||
#
|
||||
# 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
|
||||
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다.
|
||||
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
|
||||
|
||||
global:
|
||||
@@ -11,11 +11,6 @@ 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,53 +1,36 @@
|
||||
# 환경 공통 기본값과 배포 토폴로지. 환경별 차이는 values-{env}.yaml이 덮어쓴다.
|
||||
#
|
||||
# 내부망 운영은 이 Chart를 사용하지 않는다(ADR-0013 결정 7). 아래 원칙과 deployments 목록은
|
||||
# 배포 하나가 Tool Service 하나를 보는 mcp.bundles 구성(ADR-0007/0009)을 전제한다.
|
||||
# Portal이 endpoint 원천인 구성에서는 배포 하나가 N개 route를 서비스하므로 이 토폴로지가 성립하지 않는다.
|
||||
# 자세한 배경은 deploy/README.md 머리말에 있다.
|
||||
#
|
||||
# 이 Chart의 설계 원칙:
|
||||
# 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)과 배포 축(무엇을 보는가)을 섞지 않는다.
|
||||
# 1. MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007).
|
||||
# bundle 목록은 항상 한 항목이며 template이 만든다.
|
||||
# 2. 배포 대상 전체를 아래 deployments 한 곳에 적는다.
|
||||
# 설치할 때 --set deploymentKey=<key>로 하나를 고른다.
|
||||
# 배포가 10개든 20개든 파일 수가 늘지 않고, 전체 매핑을 한 화면에서 검토할 수 있다.
|
||||
# 3. 환경 축(namespace·이미지·등급별 replica)과 배포 축(어느 Tool Service를 보는가)을 섞지 않는다.
|
||||
# values-{env}.yaml에는 deployments가 없고, deployments에는 환경 정보가 없다.
|
||||
# 3. identity는 "{배포 이름}-{global.env}"로 조립한다.
|
||||
# 4. identity는 "{배포 이름}-{global.env}"로 조립한다.
|
||||
# Redis key namespace이므로 환경끼리 겹치면 서로 Tool snapshot을 덮어쓴다.
|
||||
# 사람이 손으로 적지 않게 해 실수를 구조적으로 막는다.
|
||||
# 4. 공개 path는 Route와 컨테이너가 동일하게 사용하고 rewrite하지 않는다(ADR-0009).
|
||||
# 5. 외부에서는 환경별 한 host 아래 publicPath로 구분한다. Route는 Service만 선택하고
|
||||
# 컨테이너가 같은 path를 직접 처리하므로 Registry의 1:1 경계는 바뀌지 않는다(ADR-0009).
|
||||
|
||||
# 배포 모델. 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으로 지정한다.
|
||||
# 설치할 배포를 고르는 key. 반드시 --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에서 유일해야 한다
|
||||
@@ -56,6 +39,10 @@ deploymentKey: ""
|
||||
# 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:
|
||||
@@ -107,20 +94,6 @@ 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이다.
|
||||
@@ -130,9 +103,9 @@ route:
|
||||
|
||||
# 등급별 가용성 기준. 환경별 values가 덮어쓴다.
|
||||
#
|
||||
# 배포를 등급으로 나누는 목적이 여기에 있다. 나뉘어 있어야 중요 등급에만 비용을 쓸 수 있다.
|
||||
# 다만 나누는 것만으로 가용성이 생기지는 않는다. 같은 노드 배치, namespace 쿼터,
|
||||
# 공통 Redis·클러스터 장애는 분할로 막히지 않는다.
|
||||
# portal 모드에서는 배포가 하나이므로 등급별 물리 분리가 성립하지 않는다(ADR-0013 전제 2).
|
||||
# 공통 Redis·클러스터 장애는 분할로 막히지 않는다(ADR-0007).
|
||||
tiers:
|
||||
critical:
|
||||
replicas: 2
|
||||
@@ -147,28 +120,20 @@ 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 매니페스트 조회 주기(초).
|
||||
refreshIntervalSeconds: 30
|
||||
refreshJitterSeconds: 5
|
||||
# 요청 시점에 Tool Service 매니페스트를 다시 확인할 TTL(초).
|
||||
# 요청이 없는 동안에는 Tool Service를 호출하지 않는다.
|
||||
refreshTtlSeconds: 300
|
||||
|
||||
toolService:
|
||||
# bundles 모드에서만 쓴다. MCP와 Tool Service가 같은 namespace라는 전제다.
|
||||
# MCP와 같은 namespace에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다.
|
||||
port: 8080
|
||||
manifestPath: /tool-manifest
|
||||
basePath: /mcp
|
||||
# Tool Service 호출용 API key를 담은 Secret. name이 비어 있으면 환경변수를 주입하지 않고
|
||||
# 애플리케이션 기본값을 쓴다. 운영에서는 반드시 채운다.
|
||||
apiKeySecret:
|
||||
name: ""
|
||||
key: tool-server-api-key
|
||||
|
||||
redis:
|
||||
host: redis
|
||||
|
||||
55
deploy/portal-registry.json
Normal file
55
deploy/portal-registry.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"routeKey": "cus",
|
||||
"toolServices": [
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
"serviceKey": "was-cus",
|
||||
"serviceDomain": "https://tool-cus.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"executeBasePath": "/mcp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeKey": "sal",
|
||||
"toolServices": [
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
"serviceKey": "was-sal",
|
||||
"serviceDomain": "https://tool-sal.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"executeBasePath": "/mcp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeKey": "pro",
|
||||
"toolServices": [
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
"serviceKey": "was-pro",
|
||||
"serviceDomain": "https://tool-pro.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"executeBasePath": "/mcp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeKey": "sys",
|
||||
"toolServices": [
|
||||
{
|
||||
"status": "ACTIVE",
|
||||
"serviceKey": "was-sys",
|
||||
"serviceDomain": "https://tool-sys.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"executeBasePath": "/mcp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
|
||||
## 전체 실행 흐름
|
||||
|
||||
1. Agent Builder가 배포별 공개 URL `https://{host}{publicPath}`을 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). 요청 body·header·Tool 이름은 이 선택에 관여하지 않는다.
|
||||
1. Agent Builder가 route별 공개 URL `https://{host}/mcp/{routeKey}`를 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). route key는 이 URI에서만 결정하며 설정 기본값으로 보정하지 않는다([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). Portal 모드에서 route가 없는 `/mcp` 호출은 `route key is required`로 거부한다. 요청 body·header·Tool 이름은 route 선택에 관여하지 않는다.
|
||||
2. 컨테이너의 `mcp.endpoint-path` 전용 `McpExchangeFilter`가 header를 검증/추출하고 요청 전체 deadline을 포함한 `McpRequestContext`를 만든다. 호출자 헤더 다섯 개(`guid`, `x-request-id`, `mcp-session-id`, `employee-no`, `virtual-employee-no`)는 모두 선택값이며, 응답과 downstream Tool 호출에 그대로 전파한다. `guid`는 요청 하나의 end-to-end 상관 값, `x-request-id`는 개별 HTTP 요청 식별자다.
|
||||
3. filter는 크기가 제한된 repeatable request body에서 `method`만 관찰용으로 읽고 `mcp_http_request_received` 로그를 남긴다. body, header 값, credential은 로그에 저장하지 않는다.
|
||||
4. `McpProtocolVersionValidator`가 `initialize`를 제외한 요청의 `MCP-Protocol-Version`을 supported versions와 대조한다. 누락·불일치는 Controller 진입 전 HTTP 400으로 종료한다.
|
||||
@@ -30,8 +30,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
6. `McpMethodHandlerRegistry`가 method를 명시적 handler에 연결한다.
|
||||
7. `tools/list`는 `ToolRegistryService`의 in-memory snapshot에서 실행 metadata를 얻는다. 요청 경로는 Redis를 호출하지 않으므로 Redis 장애·지연이 응답에 영향을 주지 않으며, snapshot이 비어 있는 기동 직후에만 Tool catalog provider를 한 번 조회한다. 이후 `ToolsListHandler`가 MCP SDK의 `Tool`과 `ListToolsResult`로 변환한다. local 기본 구성은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 bundle별 local manifest sample을 cold-start fallback으로 사용한다. 운영은 이 배포가 보는 Tool Service 매니페스트의 사용 가능한 성공본만 원천으로 사용한다.
|
||||
8. `tools/call`은 `ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다.
|
||||
9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 안전한 Tool name을 설정의 base endpoint 뒤에 붙여 `POST {baseEndpoint}/{toolName}` 요청과 metadata timeout을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다.
|
||||
10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. endpoint는 local catalog의 `_meta.endpoint` 또는 운영 배포 설정의 `baseEndpoint`에서만 가져오므로 Agent Builder 입력이나 Tool Service 매니페스트로 outbound 대상이 바뀌지 않는다.
|
||||
9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `inputSchema` 자체의 안전성은 실행 시점이 아니라 `ToolMetadata` 생성 시점에 이미 검사됐다. 매니페스트 파싱·local 파일 로딩·Redis snapshot 역직렬화가 모두 같은 생성자를 지나므로 검사 지점은 하나다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다.
|
||||
10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. manifest endpoint가 절대 HTTP(S) URL이면 Tool Server가 제공한 실행 주소 원천으로 허용하고, 상대 경로이면 Portal registry의 `serviceDomain` 뒤에 붙인다.
|
||||
11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다.
|
||||
12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent`의 `result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다.
|
||||
13. local과 운영 모두 같은 `name` lookup, endpoint/timeout, inputSchema validation 경로를 사용한다.
|
||||
@@ -44,6 +44,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
|---|---|---|
|
||||
| `McpController` | `transport/http` | `mcp.endpoint-path`의 단일 공개 endpoint, parser/handler 연결, notification 202와 initialize UUID header 선택 |
|
||||
| `McpRequestContextFactory` | `transport/http` | 호출자 헤더 5종 추출. correlation 값 형식 검증, 사원 식별자는 해석하지 않고 주입 위험 문자만 차단 |
|
||||
| `McpRouteKeyValidator` | `transport/http` | `/mcp/{routeKey}`의 route가 현재 서버가 아는 route인지 확인하는 transport 전용 port. memory snapshot만 읽고 registry를 직접 참조하지 않아 패키지 경계를 유지 |
|
||||
| `McpRequestContextHolder` | `context` | 요청 수명 ThreadLocal 저장; 세션 저장소가 아님 |
|
||||
| `JsonRpcRequestParser` | `jsonrpc` | JSON-RPC envelope shape 검증과 내부 request 정규화 |
|
||||
| `McpMethodHandlerRegistry` | `method` | `Handler` 전략과 method dispatch를 한 경계에서 관리 |
|
||||
@@ -52,10 +53,12 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
| `LocalFileToolRegistryClient` | `registry` | 매니페스트 조회를 끈 local profile에서 legacy JSON/manifest fixture를 읽어 테스트 Tool 목록을 제공 |
|
||||
| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 |
|
||||
| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 |
|
||||
| `PortalToolRegistryClient` | `registry` | Portal registry를 route → Tool Service 목록(`bundlesByRoute`)으로 유지하고 route별 manifest를 조회. 한 route의 실패는 `fetchRouteToolsSafely()`로 격리해 다른 route 수집을 막지 않음 |
|
||||
| `RedisPortalRegistryCache` | `registry` | Portal Registry API 장애 시 endpoint registry JSON을 읽는 선택적 Redis fallback. route별 Tool snapshot key와 분리 |
|
||||
| `ToolSchemaReferencePolicy` | `registry` | `inputSchema`가 문서 밖을 가리키는 `$ref`·`$dynamicRef`와 미지원 dialect를 거부([ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)) |
|
||||
| `ToolSchemaPatternPolicy` | `registry` | `pattern` 정규식의 길이·무한 수량자·중첩 반복을 제한하고 `maxLength` 동반을 요구. `patternProperties`는 거부([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)) |
|
||||
| `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; 실패 시 애플리케이션 생존 |
|
||||
| `ToolRegistryRefreshScheduler` | `registry` | `ApplicationReadyEvent`에서 warm start와 원천 preload를 한 번 실행. 주기 실행은 하지 않으며 이름과 달리 scheduler가 아니다. 실패해도 애플리케이션은 생존 |
|
||||
| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 |
|
||||
| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping |
|
||||
| `ToolRoutingService` | `execute` | 단일 POST endpoint와 timeout 확정, 기본 URI 검증 |
|
||||
@@ -67,6 +70,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
| `McpProtocolVersionValidator` | `transport/http` | `initialize` 이후 HTTP `MCP-Protocol-Version`의 지원 여부 검증; 서버 상태를 저장하지 않음 |
|
||||
| `TraceLogger` | `observability` | context의 guid/requestId를 직접 포함하는 최소 key=value 경계 로그. 사원 식별자는 기록하지 않음 |
|
||||
| `McpExceptionHandler` | `transport/http` | JSON parse, JSON-RPC, 예상 밖 오류의 표준 response 변환 |
|
||||
| `AgentRoutingHintsProperties` | `config` | initialize 응답 `_meta`에 넣을 Tool Server routing manifest 조회 경로 설정(기본 `/tool-service-manifest`) |
|
||||
| `LocalFixtureProperties` | `config` | 실제 Tool Server가 없는 local·dev 환경의 임시 manifest·응답 파일 위치. 기본 비활성이며 실서버 확보 후 제거 대상 |
|
||||
|
||||
Spring Boot 3.5가 관리하는 Jackson 2 databind 모델과 annotation API는 `com.fasterxml.jackson.*` namespace를 사용한다. Registry 응답의 unknown field 무시는 회귀 테스트로 검증한다.
|
||||
|
||||
@@ -147,10 +152,22 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
|
||||
중복 실행 방지는 Tool Service의 책임이다. retry에서 같은 `guid`를 재사용해 멱등성 키로 삼을지는
|
||||
[미합의 항목](extension-points.md)이며, 합의 전에는 MCP가 이를 보장한다고 가정하지 않는다.
|
||||
|
||||
## Tool 호출 retry
|
||||
|
||||
MCP는 Tool 호출 실패를 제한적으로 재시도한다. 세 조건이 모두 참일 때만 재시도하며, 하나라도 거짓이면 단발 호출이다(`ToolRoutingService:64`).
|
||||
|
||||
1. `mcp.tool-client.retry.enabled`가 참이고 `max-attempts`가 2 이상이다(기본 `true`, `2`).
|
||||
2. HTTP status가 `retry-on-http-status` 목록에 있다(기본 408, 429).
|
||||
3. Tool이 annotations로 안전하다고 선언했다.
|
||||
|
||||
3번은 `ToolMetadata.retrySafeByAnnotation()`이 공개 정의의 annotations로 판단한다. `destructiveHint`가 참이면 **항상 금지**하고, 그렇지 않은 경우에만 `readOnlyHint` 또는 `idempotentHint`가 참이면 허용한다. annotations가 없으면 허용하지 않는다. 즉 **선언하지 않은 Tool은 재시도하지 않는다.**
|
||||
|
||||
재시도는 같은 요청 deadline 안에서 일어나므로 `remainingMillis()` 예산을 넘지 못한다. 멱등성 자체는 Tool Service의 책임이며, MCP는 Tool이 선언한 annotations를 신뢰할 뿐 검증하지 않는다([ADR-0004](decisions/ADR-0004-execution-guardrails.md)).
|
||||
|
||||
## Protocol version 협상과 검증
|
||||
|
||||
- 서버는 `mcp.protocol.supported-versions`와 `mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다.
|
||||
- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=true`를 제공한다.
|
||||
- `initialize` 응답은 요청의 JSON-RPC `id`를 그대로 반환하며, preferred version과 `serverInfo(name/title/version)`, `capabilities.tools.listChanged=false`를 제공한다.
|
||||
- 이 서버는 stateless이므로 협상 결과를 session에 저장하지 않는다. `initialize` 이후 Agent Builder는 모든 MCP HTTP 요청에 `MCP-Protocol-Version: <initialize 응답 protocolVersion>`을 포함해야 하며, 서버는 매 요청을 독립적으로 검증한다.
|
||||
- header가 누락되거나 지원하지 않는 값이면 JSON-RPC error가 아닌 HTTP `400 Bad Request`를 반환한다. 오류 body는 `error`, `message`, `supportedVersions`, `guid`를 포함해 호출자가 올바른 header를 진단할 수 있게 한다.
|
||||
|
||||
@@ -161,6 +178,12 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
|
||||
- Agent Builder는 MCP 2025-11-25 lifecycle에 따라 `notifications/initialized`를 보낸다. 서버는 이를 저장하거나 이후 요청의 readiness gate로 사용하지 않는다.
|
||||
- `InitializedNotificationHandler`는 id 없는 notification을 HTTP 202으로 수용한다. 이는 Tool 실행 준비 상태를 메모리에 세우는 동작이 아니므로 replica 간 affinity가 필요 없다.
|
||||
|
||||
## Agent routing hint
|
||||
|
||||
`mcp.agent-routing-hints.enabled`가 켜져 있고 요청에 route가 있으면, `initialize` 응답의 `_meta`에 `toolServers` 배열을 실어 보낸다. `InitializeHandler`가 해당 route의 Tool Server에서 `mcp.agent-routing-hints.manifest-path`(기본 `/tool-service-manifest`)를 조회해 받은 JSON을 **변환 없이 그대로** 감싼다.
|
||||
|
||||
기능이 꺼져 있거나 route가 없으면 `_meta`를 붙이지 않고 표준 `initialize` 응답만 반환한다. 이 값은 Agent Builder에 주는 힌트이며 MCP의 Tool 실행 경로는 이를 읽지 않는다.
|
||||
|
||||
## Tool metadata 갱신 장애 시나리오
|
||||
|
||||
요청 경로는 memory만 읽으므로 Redis 상태가 등장하지 않는다.
|
||||
@@ -198,19 +221,25 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다.
|
||||
|
||||
## Portal Registry and Tool manifest refresh
|
||||
|
||||
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server endpoint 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 endpoint 목록을 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 endpoint 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 endpoint 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory endpoint snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 endpoint 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 endpoint 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
|
||||
로컬 검증에서는 `mcp.portal.registry-url`을 `file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 주기 endpoint refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` 주기 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다.
|
||||
|
||||
노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 설정된 `base-endpoint`에 요청 name을 path segment로 붙여 `tools/call`을 POST한다.
|
||||
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain`과 `manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후 갱신은 주기 실행이 아니라 **요청 시점 TTL 만료**로 일어난다. 요청이 들어오면 `ToolRegistryService`가 `mcp.portal.refresh-ttl-seconds`가 지났을 때만 포털 registry를, `mcp.registry.refresh-ttl-seconds`가 지났을 때만 해당 route의 manifest를 다시 조회한다. 아직 snapshot이 없는 route는 TTL과 무관하게 조회하며, 포털 endpoint 목록 변경이 그 route의 마지막 manifest 조회보다 나중이면 TTL이 남아 있어도 manifest를 다시 읽는다. 요청이 없으면 갱신도 일어나지 않는다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
|
||||
|
||||
노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta`와 `endpoint` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 top-level `endpoint` 또는 `_meta.endpoint`를 내부 실행 endpoint로 사용한다.
|
||||
|
||||
이 fixture는 연동 확인용이며 실제 고객·계약·수납·지급 데이터를 담지 않는다.
|
||||
|
||||
운영 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` 구성에서 이 목록은 항상 한 항목이었다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이었다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
|
||||
**MCP 배포 하나가 N개 route를 서비스하고, route 하나에 N개 Tool Service가 붙는다**([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). 카탈로그 병합 단위는 route다. 이전의 1:1 전제([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md), `Superseded`)는 매핑이 배포 시점에 확정된다는 가정 위에 있었으나, 매핑의 원천이 Portal로 옮겨지면서 성립하지 않는다.
|
||||
|
||||
이 구성에서 각 배포는 같은 환경 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다.
|
||||
등급별 물리 격리는 이 구조에서 얻지 못한다. 무엇이 남는지는 ADR-0013의 격리 표가 정본이며, 요약하면 route별 snapshot 보관과 route 간 갱신은 격리되지만 프로세스 자원·배포·재기동은 전 route가 공유한다.
|
||||
|
||||
**내부망 운영은 위 구성을 쓰지 않는다.** 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에 있다.
|
||||
`mcp.bundles` 기반 1:1 구성은 코드에 남아 있어 local 검증과 1:1 배포에서 유효하고 `HelmDeploymentContractTest`가 그 계약을 검사한다. 내부망 운영 대상인지는 ADR-0013이 정하지 않는다.
|
||||
|
||||
Route는 path로 Service만 선택하고 컨테이너가 `mcp.endpoint-path`와 그 아래 `{routeKey}`를 직접 처리한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)의 결정 4는 ADR-0013이 대체한다). `McpController`는 `${mcp.endpoint-path}`와 `${mcp.endpoint-path}/{routeKey}` 두 패턴을 받는다.
|
||||
|
||||
route 매핑은 애플리케이션 안에 있다. `PortalToolRegistryClient`가 Portal registry를 route → Tool Service 목록으로 유지하고, `ToolRegistryService`가 route별 snapshot을 들고, `McpRouteKeyValidator`가 등록되지 않은 route를 controller 진입 전에 거부한다. snapshot과 Redis key는 route별로 분리되지만 Deployment·readiness·connection pool은 전 route가 공유하며, 공유되는 장애 지점은 프로세스 자체와 OpenShift ingress·DNS다.
|
||||
|
||||
운영 상태는 외부 ingress가 아니라 management port(기본 9090)의 `GET /actuator/toolBundles`로 확인한다.
|
||||
|
||||
@@ -224,4 +253,9 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server e
|
||||
- 최종 확인: `.\gradlew.bat clean check`, `bootJar`, 실행 JAR의 initialize → notification → tools/list 흐름
|
||||
## Tool list change notification
|
||||
|
||||
`initialize`는 `capabilities.tools.listChanged=true`를 선언한다. 배경 Registry refresh가 기존 route snapshot과 다른 Tool 목록을 성공적으로 확보하면 `ToolListChangedEvent`가 표준 `notifications/tools/list_changed` JSON-RPC notification envelope를 만든다. 현재 HTTP 단발 응답 transport는 notification을 직접 push하지 않으며, SSE/Streamable HTTP 전송 계층이 추가되면 이 이벤트를 route별 Agent 연결에 전달하고 Agent Builder가 `tools/list`를 다시 호출한다.
|
||||
`initialize`는 `capabilities.tools.listChanged=false`를 선언한다(`InitializeHandler`의 `ServerCapabilities.builder().tools(false)`). 현재 HTTP 단발 응답 transport가 notification을 push할 수 없으므로, 보내지 못하는 능력을 선언하지 않는 쪽을 택한 것이다.
|
||||
|
||||
내부적으로는 배경 Registry refresh가 기존 route snapshot과 다른 Tool 목록을 성공적으로 확보하면 `ToolListChangedEvent`가 표준 `notifications/tools/list_changed` JSON-RPC notification envelope를 만든다. 이 이벤트는 아직 소비되지 않는다. SSE/Streamable HTTP 전송 계층이 추가되면 이 이벤트를 route별 Agent 연결에 전달하고 선언을 `true`로 바꾼다. 그때까지 Agent Builder는 자체 주기로 `tools/list`를 다시 호출해야 한다.
|
||||
|
||||
|
||||
- 임시 검증에서 Agent↔MCP와 MCP↔Tool Service payload를 확인해야 하면 `mcp.trace.payload-logging-enabled=true`를 켠다. 이 로그는 JSON 한 줄 형태로 요청·응답 본문을 남기므로 운영 기본값은 false이며, 검증 후 즉시 꺼야 한다.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {
|
||||
"tools": {
|
||||
"listChanged": true
|
||||
"listChanged": false
|
||||
}
|
||||
},
|
||||
"serverInfo": {
|
||||
|
||||
@@ -7,12 +7,9 @@
|
||||
- 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 선택 정책
|
||||
|
||||
@@ -21,78 +18,63 @@ correlation 값일 뿐 서버는 initialize 성공 시 이를 발급하지만
|
||||
- `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,
|
||||
`tools/list`는 `result.tools`에 현재 snapshot의 공개 Tool 필드(`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`)를 반환한다. `_meta`의 version, endpoint, HTTP method, timeout, cache 설정은 실행·운영 metadata이므로 MCP 공개 응답에 포함하지 않는다.
|
||||
|
||||
현재 `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)를 따른다.
|
||||
|
||||
## 호환성 메모
|
||||
|
||||
|
||||
@@ -1,51 +1,36 @@
|
||||
# Portal-MCP 계약 문서
|
||||
|
||||
이 디렉터리는 Portal과 MCP Server 사이의 **Tool Server endpoint 목록 조회 계약**을 관리한다.
|
||||
이 디렉터리는 포털과 MCP Server 사이의 Tool Server registry 조회 계약을 관리한다.
|
||||
|
||||
```text
|
||||
Portal ──[portal-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Service
|
||||
(endpoint 목록) (Tool 목록과 실행)
|
||||
Portal ──[portal-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Server
|
||||
▲
|
||||
└──[agent-builder-mcp 계약]── Agent Builder
|
||||
```
|
||||
|
||||
| 문서 | 상태 | 용도 |
|
||||
|---|---|---|
|
||||
| [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가 붙을 수 있다.
|
||||
| [protocol-v0.1-registry.md](protocol-v0.1-registry.md) | Implemented | route별 Tool Server 목록 조회 계약. 응답 형태, 필드, 실패 동작, 갱신 주기 |
|
||||
|
||||
## 현재 원칙
|
||||
|
||||
- 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-보안-요구사항)에 있다.
|
||||
- 포털은 **route가 무엇이고 그 route에 어떤 Tool Server가 있는가**만 소유한다. `serviceDomain`과 `manifestPath`까지다.
|
||||
- Tool 목록과 Tool 실행 endpoint는 포털이 아니라 Tool Server 매니페스트에서 온다([ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||
- registry 응답은 그 시점의 전체 상태다. 증분은 없다.
|
||||
- 조회 실패는 route 삭제가 아니다. 성공한 registry가 route를 제외했을 때만 제거를 반영한다.
|
||||
- route 조회는 서로 독립이지만, 한 route 안에서는 전부 아니면 전무다. 부분 목록으로 snapshot을 만들지 않는다.
|
||||
- MCP는 요청 경로에서 포털을 호출하지 않는다. route key 검증도 in-memory snapshot만 본다.
|
||||
|
||||
## 예제와 검증
|
||||
## 관련 문서
|
||||
|
||||
[examples/registry-v0.1](examples/registry-v0.1/)의 응답 JSON을 `PortalRegistryContractExampleTest`가 직접 읽어
|
||||
`PortalToolRegistryClient`의 실제 파싱 경로에 태운다. 예제와 구현은 같은 변경에서 함께 고친다.
|
||||
- 요청 URL의 route key 규약: [Agent Builder 계약 v0.3](../agent-builder-mcp/protocol-v0.3-streaming-policy.md#공개-url과-route-key)
|
||||
- 매니페스트 조회·실행 계약: [Tool Service 계약 v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md)
|
||||
|
||||
## 현재 producer는 외부망 검증용 PoC다
|
||||
## 운영 적용 전 확정할 항목
|
||||
|
||||
운영 Portal은 아직 이 API를 제공하지 않는다. 현재 응답을 만드는 것은 외부망 통합 검증용 PoC Portal이며,
|
||||
이 계약 문서가 **PoC와 운영 Portal이 공유해야 할 유일한 정본**이다.
|
||||
PoC 구현이 저장소를 떠나도 이 문서와 예제는 남는다.
|
||||
1. 포털 API의 인증 방식과 MCP → 포털 방향 NetworkPolicy
|
||||
2. `registryRevision`의 형식과 변경 통지 방식
|
||||
3. route 추가·폐기 시 rolling 호환 기간
|
||||
4. 저장소 샘플(`config/local-toolserver-info-sample-v1.json`, `deploy/portal-registry.json`)을 이 계약에 맞추는 시점
|
||||
|
||||
운영 적용 전에 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)을 따른다.
|
||||
상세 필드와 장애 처리는 [v0.1 계약](protocol-v0.1-registry.md)을 따른다.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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: []
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,227 +1,117 @@
|
||||
# Portal-MCP Registry 조회 계약 v0.1
|
||||
# Portal-MCP Tool Server Registry 계약 v0.1
|
||||
|
||||
- 상태: **MCP 측 구현 완료, Portal 측 미합의**
|
||||
- 기준일: 2026-08-14
|
||||
- 조회 endpoint: `GET {mcp.portal.registry-url}` — Portal이 제공
|
||||
- 구현: `PortalToolRegistryClient` (`@ConditionalOnProperty(mcp.portal.enabled=true)`)
|
||||
- 상태: **Implemented** (MCP 서버 측 구현 완료, 포털 측 합의 대기)
|
||||
- 기준일: 2026-08-22
|
||||
- 조회 위치: `mcp.portal.registry-url` — HTTP(S) Portal API 또는 `file:`/`classpath:` 로컬 리소스
|
||||
- 활성 조건: `mcp.portal.enabled=true`
|
||||
- 구현: `PortalToolRegistryClient`
|
||||
|
||||
## 1. 계약 범위와 원칙
|
||||
|
||||
Portal은 route별로 **어떤 Tool Server가 있고 그 주소가 무엇인지**를 관리한다.
|
||||
MCP는 이 목록을 주기적으로 조회해 Tool Service 매니페스트 조회 대상을 결정한다.
|
||||
포털은 **route별로 어떤 Tool Server가 있는가**만 알려 준다. Tool 목록과 Tool 실행 주소는 포털이 아니라 각 Tool Server의 매니페스트에서 온다([Tool Service-MCP Bundle 조회 계약 v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md), [ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||
|
||||
| 원칙 | 내용 |
|
||||
|---|---|
|
||||
| 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가 가져온다 | 포털은 registry를 제공만 한다. MCP에 push하지 않는다 |
|
||||
| 포털은 route와 Tool Server만 소유 | `serviceDomain`과 `manifestPath`까지다. Tool 목록·실행 endpoint는 매니페스트가 정한다 |
|
||||
| registry는 전체 상태 | 응답은 그 시점 route 전체다. 증분 없음 |
|
||||
| route 단위 격리 | 한 route의 manifest 조회 실패가 다른 route의 snapshot을 지우지 않는다 |
|
||||
| 요청 경로는 조회하지 않는다 | `tools/list`·`tools/call`과 route key 검증은 in-memory snapshot만 본다 |
|
||||
|
||||
`mcp.bundles`를 쓰는 구성과의 차이는 **하나뿐**이다. Tool Server 주소가 배포 YAML에서 오느냐
|
||||
Portal에서 오느냐. 주소를 확보한 다음의 매니페스트 조회·검증·병합은
|
||||
[tool-service-mcp v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md)를 그대로 재사용한다.
|
||||
## 2. 응답 형태
|
||||
|
||||
## 2. Portal이 제공하는 endpoint
|
||||
두 가지를 모두 받는다. `routes`가 배열이면 집계형으로, 아니면 단일 route로 해석한다.
|
||||
|
||||
| Method | Path | 용도 | MCP가 호출하는가 |
|
||||
**집계형** — 한 번의 호출로 모든 route를 받는다. 운영에서 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"registryRevision": "portal-registry-2026-08-22-01",
|
||||
"routes": [
|
||||
{
|
||||
"routeKey": "cus",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "was-cus",
|
||||
"serviceDomain": "https://tool-cus.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"namePrefix": "",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**단일 route형** — `routes` 없이 최상위에 `routeKey`와 `toolServices`를 둔다.
|
||||
|
||||
```json
|
||||
{
|
||||
"routeKey": "cus",
|
||||
"toolServices": [ { "serviceKey": "was-cus", "serviceDomain": "https://tool-cus.devjun.net", "manifestPath": "/tool-manifest", "status": "ACTIVE" } ]
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 필드
|
||||
|
||||
| 필드 | 위치 | 필수 | MCP 처리 |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/portal/registry` | 전체 route 집계 조회 | **예. 유일한 호출 대상** |
|
||||
| `GET` | `/api/portal/registry/{routeKey}` | 단일 route 조회 | 아니오 (§5 참고) |
|
||||
| `routes[]` | 최상위 | 선택 | 배열이면 집계형. 없으면 최상위를 단일 route로 읽는다 |
|
||||
| `routeKey` | route | **필수** | 정규화 후 route 식별자. 요청 URL `/mcp/{routeKey}`와 대조한다 |
|
||||
| `toolServices[]` | route | **필수** | 이 route가 보는 Tool Server 목록 |
|
||||
| `serviceKey` | service | **필수** | bundle id로 사용. 매니페스트의 `bundleId`와 일치해야 한다 |
|
||||
| `serviceDomain` | service | **필수** | Tool Server 주소. 후행 `/`는 제거한다. 매니페스트의 **상대** endpoint를 절대 URL로 바꾸는 기준이 된다 |
|
||||
| `manifestPath` | service | **필수** | 매니페스트 경로. `serviceDomain + manifestPath`가 조회 주소다 |
|
||||
| `status` | service | 선택 | 기본 `ACTIVE`. 대소문자 무시하고 `ACTIVE`가 아니면 그 서비스를 건너뛴다 |
|
||||
| `namePrefix` | service | 선택 | 기본 `""`. Tool 이름 접두사 검증에 사용한다 |
|
||||
| `registryRevision` | 최상위 | 선택 | 변경 진단·로그용. 호출 대상 결정에는 쓰지 않는다 |
|
||||
|
||||
MCP는 `mcp.portal.registry-url`에 설정된 **하나의 URL만** 호출한다.
|
||||
route별로 나눠 호출하지 않는다. 따라서 **`registry-url`은 집계 endpoint를 가리켜야 한다.**
|
||||
**MCP가 읽지 않는 필드가 있다.** 현재 구현은 `executeBasePath`, `displayName`, `toolEndpoints`를 무시한다. 응답에 있어도 오류가 아니지만 동작에 영향을 주지 않으므로, 포털이 이를 근거로 실행 주소를 통제할 수 있다고 가정하면 안 된다.
|
||||
|
||||
> `registry-url`에 `{route}` placeholder를 쓸 수 있게 되어 있으나,
|
||||
> 현재 구현은 registry 갱신 시 `{route}`를 **항상 빈 문자열로** 치환한다(`registryUrl("")`).
|
||||
> 즉 `/api/portal/registry/{route}` 형태로 설정하면 `/api/portal/registry/`를 호출해 실패한다.
|
||||
> **placeholder를 쓰지 않는다.**
|
||||
## 4. 실패 동작
|
||||
|
||||
단일 route 조회 endpoint는 Portal 화면과 운영 확인용으로 남아 있으며 MCP 경로가 아니다.
|
||||
다만 응답 shape는 MCP가 파싱할 수 있는 형태를 유지한다(§4.2). 이유는 §4.3에 있다.
|
||||
| 상황 | MCP 동작 |
|
||||
|---|---|
|
||||
| registry 조회 실패 | 이미 확보한 in-memory endpoint 목록 유지. memory가 비어 있으면 `mcp.redis.portal-registry-key`의 Redis fallback을 읽는다 |
|
||||
| Redis fallback도 실패 | 원천 미확보로 처리하고 다음 주기에 재시도 |
|
||||
| route에 ACTIVE 서비스가 하나도 없음 | `Portal registry has no active Tool Service`로 그 route 조회 실패 |
|
||||
| 한 Tool Server의 매니페스트에 사용 가능한 성공본이 없음 | 그 route 전체를 실패 처리. 부분 목록을 채택하지 않는다 |
|
||||
| route 간 Tool name 중복 또는 `maxToolsTotal` 초과 | 같은 이유로 실패 처리 |
|
||||
| registry에서 사라진 route | 다음 갱신에 in-memory snapshot에서도 제거 |
|
||||
|
||||
## 3. MCP 설정 (YAML)
|
||||
route 단위 격리와 catalog 교체 규칙은 Tool Service 계약 v0.2 §7과 같은 원칙을 따른다. 조회는 route마다 독립이지만, 한 route 안에서는 전부 아니면 전무다.
|
||||
|
||||
예시는 [mcp-portal-config.yaml](examples/registry-v0.1/mcp-portal-config.yaml)에 있다.
|
||||
## 5. 갱신 주기
|
||||
|
||||
- `mcp.portal.refresh-interval-seconds` (기본 300초): 포털 registry만 다시 읽는다.
|
||||
- `mcp.registry.refresh-interval-seconds`: 이미 확보한 Tool Server 목록의 매니페스트만 다시 읽는다.
|
||||
|
||||
기동 preload는 포털 registry를 먼저 호출한 뒤 매니페스트를 조회한다. 두 주기는 독립이다.
|
||||
|
||||
## 6. 로컬 검증
|
||||
|
||||
`registry-url`에 `file:` 또는 `classpath:` 리소스를 지정하면 포털 서버 없이 같은 계약으로 읽는다. 이후 매니페스트 조회·route별 snapshot 갱신·Redis fallback 규칙은 HTTP API를 쓸 때와 동일하다.
|
||||
|
||||
```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: []
|
||||
registry-url: file:./config/local-toolserver-info-sample-v1.json
|
||||
refresh-interval-seconds: 15
|
||||
```
|
||||
|
||||
| 항목 | 필수 | 설명 |
|
||||
## 7. 저장소의 샘플 파일
|
||||
|
||||
두 샘플이 있고, 현재 서로 다르다. 이 계약을 정본으로 삼고 맞춰야 한다.
|
||||
|
||||
| 파일 | 용도 | 이 계약과의 차이 |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
| `config/local-toolserver-info-sample-v1.json` | 로컬 검증용 | MCP가 읽지 않는 `displayName`을 포함 |
|
||||
| `deploy/portal-registry.json` | 배포 참고용 | MCP가 읽지 않는 `executeBasePath`를 포함하고 `registryRevision`이 없다 |
|
||||
|
||||
`mcp.portal.enabled=true`이면 `mcp.bundles`는 비운다. endpoint 원천이 둘이 되지 않게 한다.
|
||||
## 8. 열린 항목
|
||||
|
||||
> 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 주소로 치환하며,
|
||||
나머지 필드는 파일 그대로 사용한다. **예제를 고치면 이 테스트가 함께 깨져야 한다.**
|
||||
- 포털 API의 인증 방식은 이 계약이 정하지 않는다. MCP는 인증·인가를 하지 않으므로([ADR-0006](../../decisions/ADR-0006-no-authentication-in-mcp.md)) 네트워크 경계에서 통제한다.
|
||||
- `registryRevision`의 형식을 문자열로 고정할지 정하지 않았다. 현재 구현은 값을 로그·진단에만 쓰므로 형식에 의존하지 않는다.
|
||||
- 즉시 refresh 알림: 주기 반영으로 부족하다는 운영 근거가 생길 때 검토한다.
|
||||
|
||||
@@ -45,7 +45,7 @@ ToolBundleRegistryClient.fetchTools()
|
||||
-> ToolBundleDiscovery.discoverAll()
|
||||
-> GET {manifestUrl}
|
||||
-> bundleId / tools[] / Tool 필수 필드 검증
|
||||
-> ToolMetadata 생성 (endpoint는 MCP 배포 설정의 baseEndpoint 사용)
|
||||
-> ToolMetadata 생성 (endpoint는 Tool Service manifest의 top-level endpoint 또는 _meta.endpoint 사용)
|
||||
-> enabled=false Tool 제외
|
||||
-> immutable List<ToolMetadata>를 AtomicReference snapshot에 저장
|
||||
```
|
||||
@@ -213,7 +213,7 @@ Content-Type: application/json
|
||||
3. 모든 Tool에 고유한 표준 `name`, 비어 있지 않은 `description`, object 형태의 `inputSchema`, `_meta.version`을 넣는다.
|
||||
4. Tool을 숨기려면 `_meta.enabled: false`를 쓰거나 정상 전체 목록에서 제거한다. 둘의 변경 반영 시점은 다음 refresh다.
|
||||
5. 실행 주소·credential·개인정보·업무 payload를 매니페스트에 넣지 않는다.
|
||||
6. Tool 자체 실행 endpoint는 별도로 `POST {baseEndpoint}/{toolName}`을 구현한다. manifest endpoint는 실행 endpoint가 아니다.
|
||||
6. Tool 자체 실행 endpoint는 manifest의 top-level `endpoint` 또는 `_meta.endpoint`에 선언한다. 상대 경로는 Portal registry의 `serviceDomain` 뒤에 붙고, 절대 HTTP(S) URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용한다.
|
||||
|
||||
## 확인한 구현·테스트
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"_meta": {
|
||||
"version": "1.2.0",
|
||||
"endpoint": "/mcp/processing.contract.inquiry",
|
||||
"timeoutMillis": 3000,
|
||||
"enabled": true
|
||||
}
|
||||
@@ -61,6 +62,7 @@
|
||||
},
|
||||
"_meta": {
|
||||
"version": "1.0.1",
|
||||
"endpoint": "/mcp/processing.payment.history",
|
||||
"timeoutMillis": 5000,
|
||||
"enabled": true
|
||||
}
|
||||
@@ -86,6 +88,7 @@
|
||||
},
|
||||
"_meta": {
|
||||
"version": "0.9.0",
|
||||
"endpoint": "/mcp/processing.notice.send",
|
||||
"timeoutMillis": 10000,
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- 기준일: 2026-07-30
|
||||
- 대체 대상: push 등록 방식(v0.1). 채택하지 않은 이유는 §2
|
||||
- 조회 endpoint: `GET {manifestUrl}` — Tool Service가 제공
|
||||
- 실행 endpoint: `POST {baseEndpoint}/{toolName}` — 현재 구현
|
||||
- 실행 endpoint: Tool Service manifest의 top-level `endpoint` 또는 `_meta.endpoint` — 현재 구현
|
||||
|
||||
## 1. 계약 범위와 원칙
|
||||
|
||||
@@ -15,7 +15,7 @@ MCP Server는 자기 설정에 선언된 bundle의 매니페스트를 **주기
|
||||
|---|---|
|
||||
| MCP가 가져온다 | Tool Service는 매니페스트를 제공만 한다. MCP에 등록 요청을 보내지 않는다 |
|
||||
| 조회 대상은 설정이 정한다 | 어떤 bundle이 이 MCP에 속하는지는 배포 시점 YAML로 확정된다 |
|
||||
| **라우팅 주소는 설정이 소유한다** | 호출 대상 주소는 MCP 설정에서만 온다. 매니페스트가 바꿀 수 없다 |
|
||||
| **Tool Server domain은 포털이, Tool endpoint는 Tool Service가 소유한다** | 포털은 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, 개별 Tool 실행 endpoint는 Tool Service manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 온다 |
|
||||
| 매니페스트는 전체 상태 | 응답은 그 bundle의 Tool 전체 목록이다. 증분 없음 |
|
||||
| 조회 성공이 생존 신호 | 별도 heartbeat·TTL 장치가 없다 |
|
||||
| bundle 단위 조회 격리 | 한 bundle의 조회 실패가 다른 bundle의 조회를 중단시키지 않는다 |
|
||||
@@ -170,7 +170,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
|
||||
| `revision` | 아니오 | 매니페스트 버전. 변경 감지·로그·ETag에만 쓰인다 |
|
||||
| `tools` | 예 | 이 bundle이 노출하는 Tool 전체. 빈 배열은 "노출할 Tool 없음"이다 |
|
||||
|
||||
`baseEndpoint`는 **매니페스트에 넣지 않는다.** 넣어도 MCP는 무시한다(§1 세 번째 원칙).
|
||||
Tool 실행 endpoint는 각 Tool의 top-level `endpoint` 또는 `_meta.endpoint`에 넣는다. 상대 경로를 쓰면 포털 registry의 `serviceDomain` 뒤에 붙고, 절대 URL을 쓰면 Tool Service manifest가 제공한 실행 주소 원천으로 그대로 사용한다. HTTP(S)가 아닌 scheme은 거부한다.
|
||||
|
||||
### `tools[]` 필드
|
||||
|
||||
@@ -179,7 +179,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
|
||||
| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 |
|
||||
| `title` | 아니오 | 표시용 이름 |
|
||||
| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 |
|
||||
| `inputSchema` | 예 | JSON Schema 2020-12. 아래 **schema 제약**을 만족해야 한다 |
|
||||
| `inputSchema` | 예 | JSON Schema 2020-12 |
|
||||
| `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 |
|
||||
| `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` |
|
||||
| `_meta.version` | 예 | Tool 버전 |
|
||||
@@ -189,24 +189,6 @@ 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`를 생략한다.
|
||||
@@ -317,7 +299,7 @@ management port(운영 기본 9090)에서 MCP가 알고 있는 bundle의 조회
|
||||
이미 구현되어 있는 계약이다. Tool Service는 아래를 받을 수 있어야 한다.
|
||||
|
||||
```text
|
||||
POST {baseEndpoint}/{toolName}
|
||||
POST {endpoint}
|
||||
Content-Type: application/json
|
||||
guid, x-request-id, mcp-session-id, employee-no, virtual-employee-no
|
||||
Authorization: <설정에 따라 전달>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR-0010 Tool 실행 endpoint를 Tool Service 매니페스트가 선언한다
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-19
|
||||
- 기록: 2026-08-22. 구현 커밋 `6078852`를 기준으로 사후 작성했다.
|
||||
- 관련: [ADR-0006](ADR-0006-no-authentication-in-mcp.md), [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
|
||||
|
||||
## 배경
|
||||
|
||||
이전 설계에서 Tool 실행 주소는 MCP 배포 설정이 단독으로 소유했다. `mcp.bundles[].baseEndpoint` 뒤에 Tool name을 붙여 `POST {baseEndpoint}/{toolName}`을 만들었고, 매니페스트가 endpoint 성격의 값을 담고 있어도 읽지 않았다. **Tool Service가 반환한 어떤 값도 MCP가 요청을 보내는 대상을 바꿀 수 없다**는 것이 이 설계의 핵심이었고, `docs/architecture.md`, Tool Service 계약 v0.2, `ToolBundleDiscovery`의 Javadoc, `application.yml` 주석이 같은 문장을 반복해 고정하고 있었다.
|
||||
|
||||
Portal registry를 Tool Server 목록의 원천으로 도입하면서 전제가 무너졌다. 포털은 route별로 Tool Server의 `serviceDomain`과 `manifestPath`만 제공한다. Tool 하나하나의 실행 경로는 포털이 모르고, MCP 배포 설정도 미리 알 수 없다. 기존 구조를 유지하려면 Tool을 추가하거나 경로를 바꿀 때마다 배포 설정의 endpoint 목록을 함께 고쳐야 했고, 이는 Tool Service의 배포 주기와 MCP의 배포 주기를 묶어 버린다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. Tool 실행 주소는 Tool Service 매니페스트가 선언한다. MCP는 각 Tool의 top-level `endpoint`를 먼저 읽고, 없으면 `_meta.endpoint`를 사용한다.
|
||||
2. 둘 다 없거나 비어 있으면 그 Bundle 전체를 거부한다. Tool 하나의 누락이 나머지 Tool을 조용히 통과시키지 않는다.
|
||||
3. 상대 경로는 Portal registry가 제공한 `serviceDomain` 뒤에 붙여 절대 URL로 만든다. 이것이 운영에서 기대하는 형태다.
|
||||
4. 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용한다. scheme이 HTTP(S)가 아니거나 host가 없으면 거부한다. 프로토콜 상대 주소(`//host/path`)와 개행이 섞인 값도 거부한다.
|
||||
5. `mcp.bundles[].baseEndpoint`는 더 이상 실행 주소의 정본이 아니다. 상대 경로를 해석하는 기준으로만 남으며, 절대 HTTP(S)여야 한다.
|
||||
6. `endpoint`는 내부 실행 정보이므로 `_meta`와 함께 제거해 `tools/list` 공개본에 내보내지 않는다.
|
||||
|
||||
```text
|
||||
manifest endpoint = "/mcp/processing.contract.inquiry" (운영 관례)
|
||||
-> https://tool-cus.devjun.net/mcp/processing.contract.inquiry
|
||||
|
||||
manifest endpoint = "https://other.example/tool" (허용되지만 위험)
|
||||
-> https://other.example/tool
|
||||
```
|
||||
|
||||
## 영향
|
||||
|
||||
- Tool을 추가하거나 실행 경로를 바꿀 때 MCP 배포 설정을 함께 바꾸지 않아도 된다. Tool Service가 매니페스트만 갱신하면 다음 refresh에 반영된다.
|
||||
- **신뢰 경계가 이동한다.** 이전에는 배포 설정이 outbound 대상을 봉인했으나, 이제는 매니페스트가 결정한다. 매니페스트가 절대 URL을 선언하면 MCP는 그 호스트로 요청을 보낸다.
|
||||
- 검증은 두 지점에 있다. discovery 시점에 `ToolBundleDiscovery`가 scheme·host·프로토콜 상대 주소·개행을 확인하고, 실행 시점에 `ToolRoutingService.validateEndpoint()`가 절대 HTTP(S)인지 다시 확인한다. 둘 다 **형식 검사이며 도메인 허용목록은 없다.** 따라서 매니페스트 원천의 신뢰성이 곧 outbound 대상의 신뢰성이다.
|
||||
- 네트워크 계층의 완화도 없다. `deploy/helm/mcp-server/templates/networkpolicy.yaml`은 `policyTypes: [Ingress]`만 선언하므로 outbound 목적지를 제한하지 않는다. 이 저장소가 제공하는 allowlist(`route.sourceAllowlist`, NetworkPolicy)는 모두 inbound 통제다.
|
||||
- [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라 MCP는 인증·인가를 하지 않는다. 그래서 "`GET /tool-manifest`를 NetworkPolicy로 MCP Server namespace에서만 접근 가능하게 한다"는 기존 요구가 선택적 권고가 아니라 이 결정의 전제 조건이 된다.
|
||||
- endpoint 검증 실패는 Bundle 전체 거부로 처리되고 직전 정상 snapshot이 유지되므로, 잘못된 매니페스트 배포가 기존 Tool 목록을 지우지는 않는다.
|
||||
- 계약 문서와 예제가 함께 갱신됐다. `docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json`이 `endpoint`를 포함하며, `ToolBundleContractExampleTest`가 문서와 구현의 일치를 고정한다.
|
||||
|
||||
## 남은 위험
|
||||
|
||||
- **도메인 허용목록 부재.** 매니페스트가 임의의 HTTP(S) 호스트를 지정할 수 있고, 애플리케이션 검사도 네트워크 정책도 이를 좁히지 않는다. 내부망 운영 전에 두 방향 중 하나를 정해야 한다.
|
||||
- `mcp.tool-domains` 형태의 allowlist를 두고 절대 URL을 그에 대조한다.
|
||||
- 절대 URL을 아예 거부하고 상대 경로만 허용해 목적지를 Portal registry의 `serviceDomain`으로 봉인한다. 운영 예제가 이미 상대 경로만 쓰고 있어 비용이 가장 낮고, 이전 설계의 "Tool Service가 호출 대상을 바꿀 수 없다"는 성질도 회복된다.
|
||||
- egress NetworkPolicy를 함께 검토한다. 위 두 방안 중 무엇을 택하든 애플리케이션 단독 방어보다 낫다.
|
||||
- 이 ADR은 기존 ADR을 대체하지 않는다. 뒤집힌 불변식이 ADR이 아니라 architecture 문서와 코드 주석에만 있었기 때문이다. 같은 일이 반복되지 않도록 실행 주소 관련 결정은 앞으로 이 ADR을 갱신하거나 후속 ADR로 남긴다.
|
||||
@@ -1,61 +1,47 @@
|
||||
# ADR-0013 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-16
|
||||
- 결정일: 2026-08-22
|
||||
- 대체 결정: [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-0001](ADR-0001-stateless-execution-boundary.md) · [ADR-0005](ADR-0005-standard-tool-name.md) · [ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.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를 볼지가 배포 시점에 확정된다**는 것이었다.
|
||||
[ADR-0007](ADR-0007-one-mcp-per-tool-service.md)은 MCP 배포 하나가 Tool Service 하나만 보게 하고, 어떤 Tool Service를 볼지를 `mcp.bundles`에 배포 시점으로 못박았다. 전제는 **매핑이 배포 시점에 확정된다**는 것이었다.
|
||||
|
||||
내부망 운영은 그 전제를 따르지 않기로 했다. route와 Tool Service의 매핑은 Portal이 관리하고,
|
||||
MCP는 기동할 때 Portal API에서 route 정보·Tool Service endpoint·매핑 관계를 받아 온다.
|
||||
매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.
|
||||
내부망 운영은 그 전제를 따르지 않는다. route와 Tool Service의 매핑은 Portal이 관리하고, MCP는 기동 preload와 주기 refresh에서 Portal registry를 읽어 매핑을 받는다. 매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.
|
||||
|
||||
구현은 이미 이 구조였다. `PortalToolRegistryClient`가 registry 응답을 `Map<String, List<Bundle>>`(route → Tool Service 목록)로 유지하고, `McpRequestContextFactory`가 `/mcp/{routeKey}`에서 route를 뽑고, `ToolRegistryService`가 route별 snapshot을 들고 있다. 그런데 이 경로를 정당화하는 결정 문서가 없었고, 그 사이 ADR-0007은 `Accepted` 상태로 남아 코드와 정반대되는 내용을 현재 설계 근거처럼 제시하고 있었다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. **Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다.** MCP는 기동 preload와 주기 refresh에서 Portal registry API를 조회한다. `mcp.bundles`는 비운다.
|
||||
2. **MCP 배포 하나가 N개 route를 서비스한다.** route key는 `/mcp/{routeKey}` URI에서 결정하며 설정 기본값으로 보정하지 않는다.
|
||||
1. **Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다.** MCP는 기동 preload와 주기 refresh에서 Portal registry를 조회한다.
|
||||
2. **MCP 배포 하나가 N개 route를 서비스한다.** route key는 `/mcp/{routeKey}` URI에서만 결정한다.
|
||||
3. **route 하나에 N개 Tool Service가 붙을 수 있다.** 카탈로그 병합 단위는 route다.
|
||||
4. Portal은 **주소만** 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유한다.
|
||||
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는 내부망 운영에 사용하지 않는다.
|
||||
4. Portal은 **주소만** 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유하고, Tool 실행 주소도 매니페스트가 정한다([ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||
5. 요청 경로(`tools/list`, `tools/call`, route key 검증)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다.
|
||||
6. 응답 모양과 실패 처리는 [Portal-MCP 계약 v0.1](../contracts/portal-mcp/protocol-v0.1-registry.md)이 정본이다.
|
||||
|
||||
## 근거
|
||||
|
||||
### 매핑이 동적이면 배포 축과 매핑 축을 겹칠 수 없다
|
||||
|
||||
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은 매핑을 배포 정의에 넣었다. Portal이 매핑을 소유하는 순간 **매핑 변경이 곧 배포 변경**이 되어 Portal을 원천으로 둔 의미가 사라진다. 원천이 Portal이면 배포는 매핑에 대해 중립이어야 하고, 그래서 한 배포가 N route를 서비스한다.
|
||||
|
||||
### ADR-0007의 격리 논거는 층위별로 다르게 남는다
|
||||
|
||||
격리는 약해진다. 숨기지 않고 적는다.
|
||||
ADR-0007이 지키려던 것은 가용성 등급별 격리였다. 이 구조에서 무엇이 남고 무엇이 사라지는지 숨기지 않고 적는다. 아래는 현재 main 코드 기준이다.
|
||||
|
||||
| 층위 | 격리 | 근거 |
|
||||
|---|---|---|
|
||||
| route 간 snapshot·Redis key·refresh | **유지** | `snapshotsByRoute`와 route별 Redis key로 분리 |
|
||||
| route 안 N개 Tool Service의 조회 | **유지** | bundle마다 last-good을 따로 보관하므로 한쪽 실패가 다른 쪽 조회를 멈추지 않는다 |
|
||||
| route 안 카탈로그 교체 | **없음** | 한 번도 성공하지 못한 Tool Service가 있으면 그 route 전체 교체를 거부한다 |
|
||||
| route별 snapshot 보관 | **유지** | `ToolRegistryService`가 route별 snapshot을 따로 들고, 요청은 자기 route만 읽는다 |
|
||||
| route 안 N개 Tool Service의 **조회** | **유지** | `ToolBundleDiscovery`가 bundle마다 last-good을 따로 보관한다 |
|
||||
| route 안 카탈로그 **교체** | **없음** | 사용 가능한 성공본이 없는 Tool Service가 하나라도 있으면 그 route 전체 교체를 거부한다 |
|
||||
| route 간 **갱신** | **유지** | `PortalToolRegistryClient.fetchAllTools()`가 route마다 `fetchRouteToolsSafely()`로 예외를 격리하고 실패한 route만 결과에서 뺀다 |
|
||||
| 프로세스 자원(connection pool, thread, heap) | **없음** | 전 route가 공유한다 |
|
||||
| 배포·재기동·프로세스 장애 | **없음** | 전 route가 동시에 영향을 받는다 |
|
||||
|
||||
ADR-0007이 지키려던 **가용성 등급별 물리 분리는 이 구조에서 성립하지 않는다.**
|
||||
등급 요구가 다시 생기면 이 ADR을 재검토한다(전제 2).
|
||||
**ADR-0007이 지키려던 가용성 등급별 물리 분리는 이 구조에서 성립하지 않는다.** 등급 요구가 다시 생기면 이 ADR을 재검토한다(전제 2).
|
||||
|
||||
## 전제
|
||||
|
||||
@@ -64,73 +50,38 @@ ADR-0007이 지키려던 **가용성 등급별 물리 분리는 이 구조에서
|
||||
1. route↔Tool Service 매핑의 관리 주체는 Portal이며, 매핑 변경이 MCP 재배포 없이 반영되어야 한다.
|
||||
2. 가용성 등급별 물리 분리 요구가 없다.
|
||||
3. 전 route의 Tool 총량과 매니페스트 조회 부하를 한 프로세스가 감당한다.
|
||||
4. Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다([계약 §8](../contracts/portal-mcp/protocol-v0.1-registry.md#8-보안-요구사항)).
|
||||
4. Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다.
|
||||
|
||||
## 영향
|
||||
|
||||
**실패 전파 범위를 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만 등록한다.
|
||||
- 등록되지 않은 route는 `McpRouteKeyValidator`가 controller 진입 전에 거부한다. 판단은 memory snapshot만 본다.
|
||||
- Tool 이름 유일성은 **route 안에서만** 검사한다. 서로 다른 route에 같은 이름이 있어도 거부하지 않는다.
|
||||
- `mcp.discovery.max-tools-total`은 전역이 아니라 **route 단위 상한**으로 동작한다.
|
||||
- Portal 조회 실패는 목록을 비우지 않는다. memory를 유지하고, cold start일 때만 Redis fallback을 읽는다.
|
||||
- `deploy/helm/`, `HelmDeploymentContractTest`, `values.yaml`의 `deployments`는 이 결정과 맞지 않는다. 상태 표시나 제거를 판단해야 한다.
|
||||
- `mcp.discovery.max-tools-total`은 전역이 아니라 **route 단위 상한**으로 동작한다. `merge()`가 route마다 호출되기 때문이다.
|
||||
- Portal 조회 실패는 목록을 비우지 않는다. memory를 유지하고, cold start일 때만 `mcp.redis.portal-registry-key`의 Redis fallback을 읽는다.
|
||||
- refresh 실패는 애플리케이션을 죽이지 않는다. `ToolRegistryRefreshScheduler`가 `RuntimeException`을 잡아 warn 로그만 남긴다.
|
||||
- [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정 `publicPath` 대신 `/mcp` + 동적 route로 처리하므로 결정 4만 이 ADR이 대체한다.
|
||||
- [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과 함께 정리한 항목이다. 남은 판단이 있는 것만 적는다.
|
||||
이 결정을 확정하면서 코드가 아직 따라오지 못한 지점이다. 둘 다 이 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` 위에
|
||||
겹쳐 있던 고아 블록도 제거했다. 이 결정과 무관한 기존 결함이었다.
|
||||
처음 이 문서를 쓸 때 적었던 "route 간 갱신 격리 없음"은 `6653030 Isolate route manifest failures during tool preload`으로 해소되어 위 격리 표로 옮겼다.
|
||||
|
||||
남은 판단:
|
||||
1. **warm start가 Portal 모드에서 동작하지 않는다.** `ToolRegistryService.warmStartFromSharedCache()`는 route `""`의 Redis key만 읽는다. route가 이름을 갖는 이 구성에서는 아무것도 읽지 못해, 기동 직후 빈 목록 구간을 줄이는 효과가 사라진다.
|
||||
2. **readiness가 route별 상태를 노출하지 않는다.** `ToolCatalogHealthIndicator`는 `usableSnapshot`만 detail로 내보낸다. 어느 route가 준비됐고 어느 route가 비어 있는지 관제가 알 수 없다.
|
||||
|
||||
- readiness를 route 단위로 세분화할 필요가 생기는지는 운영 관측 이후에 다시 본다. 현재는 최소 1개 route로
|
||||
UP을 판정하고 `routesWithoutSnapshot`을 detail로 노출한다(위 영향 절).
|
||||
## 남은 판단
|
||||
|
||||
- `McpProperties.Portal`에 `routeKey` 컴포넌트가 선언돼 있으나 어떤 코드도 읽지 않는다. `application.yml`에 `route-key` 키도 없고, `.portal()` 호출 다섯 곳 중 `routeKey()`를 읽는 곳이 없다. 결정 2에 따라 route는 URI에서만 오므로 이 컴포넌트는 제거 대상이다. 설정으로 기본 route를 보정하면 잘못된 단일 진입점 호출이 조용히 성공한다.
|
||||
- `deploy/helm/`의 배포별 topology와 `HelmDeploymentContractTest`는 `mcp.bundles` 기반 1:1 구성의 계약이다. 코드에 그 경로가 남아 있어 local 검증과 1:1 배포에서는 유효하지만, 내부망 운영 대상인지 여부는 이 ADR이 정하지 않는다.
|
||||
- readiness를 route 단위로 세분화할지는 운영 관측 이후에 다시 본다.
|
||||
|
||||
## 채택하지 않은 대안
|
||||
|
||||
**ADR-0007을 유지하고 배포마다 Portal의 자기 route만 조회한다.**
|
||||
격리는 지키지만 route 추가가 배포 추가가 된다. Portal이 route 목록의 원천인데 배포 topology가 그 목록을 따라가야 하므로 순환이 생긴다.
|
||||
**ADR-0007을 유지하고 배포마다 자기 route만 조회한다.** 격리는 지키지만 route 추가가 배포 추가가 된다. Portal이 route 목록의 원천인데 배포 topology가 그 목록을 따라가야 하므로 순환이 생긴다.
|
||||
|
||||
**`mcp.bundles`에 매핑을 하드코딩한다.**
|
||||
매핑 변경마다 재배포가 필요해 전제 1과 충돌한다. 또한 비Portal 경로의 `ToolBundleRegistryClient.fetchTools(routeKey)`는
|
||||
**routeKey를 읽지 않으므로** route마다 다른 카탈로그를 만들 수 없다. 모든 route가 같은 목록을 오류 없이 반환해
|
||||
라우팅이 검증되지 않은 채 통과한다.
|
||||
**`mcp.bundles`에 매핑을 하드코딩한다.** 매핑 변경마다 재배포가 필요해 전제 1과 충돌한다.
|
||||
|
||||
**route별로 프로세스를 나누고 각자 Portal을 조회한다.**
|
||||
자원 격리는 얻지만 Portal이 route 목록을 소유하는 이상 배포 수를 Portal이 정하게 된다.
|
||||
운영 중 route 추가가 배포 파이프라인을 건드린다.
|
||||
**route별로 프로세스를 나누고 각자 Portal을 조회한다.** 자원 격리는 얻지만 Portal이 route 목록을 소유하는 이상 배포 수를 Portal이 정하게 되어, 운영 중 route 추가가 배포 파이프라인을 건드린다.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| [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-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md) | Tool 실행 endpoint를 Tool Service 매니페스트가 선언 | 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,13 +34,7 @@
|
||||
|
||||
1. `GET {manifestUrl}` 제공, 인증 방식과 NetworkPolicy 범위
|
||||
2. Tool name namespace, 변경·폐기 절차와 하위 호환 기간
|
||||
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 파트와 합의가 필요하다**
|
||||
3. 허용할 JSON Schema 2020-12 keyword, 원격 `$ref`와 `format` 정책
|
||||
4. Tool별 timeout, 권한 scope, write Tool의 idempotency 보장
|
||||
5. `outputSchema`/`structuredContent` 도입 여부와 응답 검증 실패 의미
|
||||
6. 매니페스트 revision·ETag/304 및 즉시 refresh 알림의 필요성
|
||||
@@ -60,10 +54,7 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one
|
||||
## 플랫폼·DevOps와 확인할 항목
|
||||
|
||||
배포 정의를 이 저장소가 어디까지 소유하는지 확정되지 않았다.
|
||||
GitOps 저장소도 ArgoCD Application도 아직 없어, 그때까지 [Helm Chart](../deploy/helm/mcp-server/)와
|
||||
push 방식 파이프라인(`.gitea/workflows/`)을 이 저장소가 **임시로** 소유한다.
|
||||
그 방식이 무엇을 포기하는지와 넘길 때 할 일은
|
||||
[deploy/README.md](../deploy/README.md#gitops-저장소가-없는-동안의-우회)가 정본이다.
|
||||
현재는 [Helm Chart](../deploy/helm/mcp-server/)만 두고 있으며, 빌드·배포 실행 방식은 정의하지 않는다.
|
||||
|
||||
1. **Helm Chart를 어디에 두는가.** 앱 저장소인가 배포 전용 저장소인가
|
||||
2. 환경별 namespace 명명 규칙과 Agent Builder namespace.
|
||||
@@ -76,15 +67,6 @@ push 방식 파이프라인(`.gitea/workflows/`)을 이 저장소가 **임시로
|
||||
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,23 +108,8 @@ 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` 해석 범위는
|
||||
[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 업그레이드로 단언이 켜지면 테스트가 실패해 알 수 있다.
|
||||
SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다. Registry가 제공하는
|
||||
schema 자체의 허용 dialect와 `$ref` 원격 해석 정책은 운영 Registry 계약으로 별도 통제해야 한다.
|
||||
|
||||
## 6. 의도적으로 도입하지 않은 SDK 기능
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||
<classpathentry kind="output" path="bin/default"/>
|
||||
</classpath>
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>agent-test-backend</name>
|
||||
<comment>Project agent-test-backend created by Buildship.</comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.springframework.ide.eclipse.boot.validation.springbootbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -1,13 +0,0 @@
|
||||
arguments=
|
||||
auto.sync=false
|
||||
build.scans.enabled=false
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
gradle.user.home=
|
||||
java.home=
|
||||
jvm.arguments=
|
||||
offline.mode=false
|
||||
override.workspace.settings=false
|
||||
show.console.view=false
|
||||
show.executions.view=false
|
||||
@@ -1,4 +0,0 @@
|
||||
eclipse.preferences.version=1
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=21
|
||||
org.eclipse.jdt.core.compiler.compliance=21
|
||||
org.eclipse.jdt.core.compiler.source=21
|
||||
@@ -1,2 +0,0 @@
|
||||
boot.validation.initialized=true
|
||||
eclipse.preferences.version=1
|
||||
@@ -1,137 +0,0 @@
|
||||
# Agent Test Backend / Portal PoC
|
||||
|
||||
Browser-based PoC tester and hardcoded Portal Registry screen for:
|
||||
|
||||
```text
|
||||
Browser -> Agent Test Backend -> MCP Server -> Tool Server
|
||||
```
|
||||
|
||||
## Ports
|
||||
|
||||
```text
|
||||
Tool Server http://localhost:9092
|
||||
MCP Server http://localhost:8080/mcp
|
||||
Agent Test Backend http://localhost:7070
|
||||
```
|
||||
|
||||
## STS Run
|
||||
|
||||
Import this folder as an existing Gradle project:
|
||||
|
||||
```text
|
||||
C:\Users\hyo\Documents\Codex\agent-test-backend
|
||||
```
|
||||
|
||||
Run `com.example.agenttest.AgentTestBackendApplication`.
|
||||
|
||||
Default environment:
|
||||
|
||||
```text
|
||||
AGENT_TEST_PORT=7070
|
||||
MCP_ENDPOINT_URL=http://localhost:8080/mcp
|
||||
MCP_PROTOCOL_VERSION=2025-11-25
|
||||
TOOL_MANIFEST_URL=http://localhost:9092/tool-manifest
|
||||
TOOL_SERVER_API_KEY=tool-server-key
|
||||
PORTAL_REGISTRY_REVISION=1
|
||||
PORTAL_ROUTE_KEY=external
|
||||
TOOL_SERVICE_DOMAIN=http://localhost:9092
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:7070
|
||||
```
|
||||
|
||||
## Recommended Verification Order
|
||||
|
||||
1. Start Tool Server in STS.
|
||||
2. Start MCP Server with Tool Server environment values.
|
||||
3. Start Agent Test Backend.
|
||||
4. Open `http://localhost:7070`.
|
||||
5. Click `Initialize`, `Tools/List`, then run `Agent Chat` or `Tools/Call`.
|
||||
|
||||
## Portal PoC Scope
|
||||
|
||||
The current screen intentionally uses hardcoded registry data instead of DB tables:
|
||||
|
||||
```text
|
||||
MCP Route external -> http://localhost:8080/mcp
|
||||
Tool Service external-tools -> http://localhost:9092/tool-manifest
|
||||
Mapping external -> external-tools
|
||||
```
|
||||
|
||||
The MCP-facing Portal Registry API is:
|
||||
|
||||
```text
|
||||
GET http://localhost:7070/api/portal/registry/external
|
||||
```
|
||||
|
||||
For this PoC, MCP can use these IntelliJ environment variables after applying
|
||||
`C:\Users\hyo\Documents\Codex\mcp-portal-registry.patch` to the MCP project:
|
||||
|
||||
```text
|
||||
MCP_DISCOVERY_ENABLED=true
|
||||
MCP_PORTAL_ENABLED=true
|
||||
MCP_PORTAL_ROUTE_KEY=external
|
||||
MCP_PORTAL_REGISTRY_URL=http://localhost:7070/api/portal/registry/external
|
||||
MCP_REGISTRY_REFRESH_INTERVAL_SECONDS=60
|
||||
```
|
||||
|
||||
`POST /api/agent/chat` is a first Agent Backend skeleton. It currently uses a simple rule-based
|
||||
planner so it can run without an OpenAI API key:
|
||||
|
||||
```text
|
||||
"서울 날씨 알려줘" -> external.weather_lookup
|
||||
"달러 환율 알려줘" -> external.exchange_rate
|
||||
```
|
||||
|
||||
Later, replace the rule-based planner with Codex/OpenAI model selection while keeping the same MCP
|
||||
`tools/list` and `tools/call` boundary.
|
||||
|
||||
## Portal Tool Bundle Management
|
||||
|
||||
The Portal now manages both seeded pull-discovery bundles through one API and UI flow:
|
||||
|
||||
```text
|
||||
external-tools -> http://localhost:9092/tool-manifest
|
||||
business-tools -> http://localhost:9090/tool-manifest
|
||||
```
|
||||
|
||||
Bundle APIs:
|
||||
|
||||
```text
|
||||
GET /api/portal/bundles
|
||||
POST /api/portal/bundles
|
||||
PUT /api/portal/bundles/{bundleId}
|
||||
POST /api/portal/bundles/{bundleId}/sync
|
||||
```
|
||||
|
||||
The API key is write-only. Read responses expose only `apiKeyConfigured`, and logs never contain
|
||||
the key value. Manifest synchronization validates the configured Bundle ID, Tool name prefix,
|
||||
required metadata objects, and the configured execution endpoint for every mapped Tool.
|
||||
|
||||
Business Tool endpoint mappings are owned by the Portal/MCP configuration rather than the
|
||||
manifest:
|
||||
|
||||
```text
|
||||
business.customer_search -> POST http://localhost:9090/internal/tools/customer-search
|
||||
business.order_status -> POST http://localhost:9090/internal/tools/order-status
|
||||
business.ticket_create -> POST http://localhost:9090/internal/tools/ticket-create
|
||||
```
|
||||
|
||||
The Bundle definitions and synchronized snapshots are currently held in memory because this PoC
|
||||
does not include a database dependency or an existing schema/migration framework. Therefore no DB
|
||||
migration is required. Restarting the Portal restores the two seeded definitions and clears cached
|
||||
manifest snapshots. For persistent operation, the `PortalBundleService` store is the boundary to
|
||||
replace with the project's chosen repository and migration framework.
|
||||
|
||||
Start the business Tool server with pull discovery only:
|
||||
|
||||
```text
|
||||
TOOL_AUTO_REGISTER_ENABLED=false
|
||||
TOOL_HEARTBEAT_ENABLED=false
|
||||
TOOL_SERVER_PORT=9090
|
||||
```
|
||||
|
||||
After starting the Tool server, open the Portal and click `Sync Manifest` for `business-tools`.
|
||||
@@ -1,18 +0,0 @@
|
||||
server:
|
||||
port: ${AGENT_TEST_PORT:7070}
|
||||
|
||||
agent-test:
|
||||
mcp:
|
||||
endpoint-url: ${MCP_ENDPOINT_URL:http://localhost:8080/mcp}
|
||||
protocol-version: ${MCP_PROTOCOL_VERSION:2025-11-25}
|
||||
tool-server:
|
||||
manifest-url: ${TOOL_MANIFEST_URL:http://localhost:9092/tool-manifest}
|
||||
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
|
||||
portal:
|
||||
registry-revision: ${PORTAL_REGISTRY_REVISION:1}
|
||||
route-key: ${PORTAL_ROUTE_KEY:cus}
|
||||
tool-service-domain: ${TOOL_SERVICE_DOMAIN:http://localhost:9092}
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.example.agenttest: INFO
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,625 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AX HUB Portal PoC</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--surface: #ffffff;
|
||||
--line: #d7dde8;
|
||||
--text: #172033;
|
||||
--muted: #667085;
|
||||
--primary: #1d5fd1;
|
||||
--primary-dark: #164aa5;
|
||||
--soft: #eef3fb;
|
||||
--good: #087443;
|
||||
--bad: #b42318;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #162033;
|
||||
color: #fff;
|
||||
padding: 18px 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1320px, calc(100% - 32px));
|
||||
margin: 18px auto 28px;
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 5px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e7f6ee;
|
||||
color: var(--good);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 92px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
min-height: 104px;
|
||||
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
min-height: 38px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #344054;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #202939;
|
||||
}
|
||||
|
||||
button.soft {
|
||||
background: var(--soft);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
button.soft:hover {
|
||||
background: #dfe8f6;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
min-height: 300px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
background: #101828;
|
||||
color: #e5e7eb;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.answer {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
min-height: 80px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.manual-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
header,
|
||||
main,
|
||||
.two-col,
|
||||
.manual-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>AX HUB Portal PoC</h1>
|
||||
<div class="header-meta" style="text-align:left">Hardcoded Registry + Agent Backend + MCP Tool Call</div>
|
||||
</div>
|
||||
<div class="header-meta" id="config">Loading configuration...</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="stack">
|
||||
<section>
|
||||
<h2>Portal Registry</h2>
|
||||
<p class="header-meta" style="text-align:left;color:var(--muted)">필요할 때 MCP가 Registry를 다시 확인하도록 revision을 갱신합니다.</p>
|
||||
<input id="routeKey" value="cus" type="hidden">
|
||||
<div class="actions">
|
||||
<button onclick="bumpRevision()">Portal Revision 올리기</button>
|
||||
<button onclick="openNewBundle()">+ Tool Server 추가</button>
|
||||
</div>
|
||||
<details id="bundleEditor" style="margin-top:14px">
|
||||
<summary><strong>Tool Server 설정</strong></summary>
|
||||
<div style="margin-top:12px">
|
||||
<label for="bundleId">Bundle ID</label>
|
||||
<input id="bundleId" placeholder="business-tools">
|
||||
<label for="toolServerName">Tool Server 이름</label>
|
||||
<input id="toolServerName" placeholder="Business Tool Server">
|
||||
<label for="manifestUrl">Manifest URL</label>
|
||||
<input id="manifestUrl" placeholder="http://localhost:9090/tool-manifest">
|
||||
<label for="baseUrl">실행 Base URL</label>
|
||||
<input id="baseUrl" placeholder="http://localhost:9090">
|
||||
<label for="namePrefix">Tool name prefix</label>
|
||||
<input id="namePrefix" placeholder="business.">
|
||||
<label><input id="bundleEnabled" type="checkbox" checked style="width:auto"> 활성화</label>
|
||||
<details style="margin-top:12px">
|
||||
<summary>고급 설정</summary>
|
||||
<label for="pollInterval">Manifest 조회 주기(초)</label>
|
||||
<input id="pollInterval" type="number" min="5" value="60">
|
||||
<label for="apiKey">Tool Server API Key (입력 전용)</label>
|
||||
<input id="apiKey" type="password" autocomplete="new-password" placeholder="기존 Key를 유지하려면 비워두세요">
|
||||
<label for="toolEndpoints">Tool 실행 경로 매핑(JSON)</label>
|
||||
<textarea id="toolEndpoints">{}</textarea>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button onclick="saveBundle()">저장</button>
|
||||
<button class="soft" onclick="closeBundleEditor()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>MCP 연결 테스트</strong></summary>
|
||||
<label for="mcpToolServerFilter">확인할 Tool Server</label>
|
||||
<select id="mcpToolServerFilter" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
|
||||
<option value="external.">External Tool Server</option>
|
||||
<option value="business.">Business Tool Server</option>
|
||||
<option value="cus">DAP WAS CUS Tool Server</option>
|
||||
</select>
|
||||
<div class="actions">
|
||||
<button onclick="callApi('POST', mcpTestAction('initialize'))">Initialize</button>
|
||||
<button class="secondary" onclick="callApi('POST', mcpTestAction('initialized'))">Initialized</button>
|
||||
<button onclick="loadMcpTools()">Tools/List</button>
|
||||
</div>
|
||||
<div id="mcpToolList" class="stack" style="margin-top:12px"></div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>Agent 테스트</strong></summary>
|
||||
<label for="message">사용자 요청</label>
|
||||
<textarea id="message" class="chat-box">서울 날씨 알려줘</textarea>
|
||||
<div class="actions">
|
||||
<button onclick="sendChat()">Agent 실행</button>
|
||||
<button class="soft" onclick="setMessage('서울 날씨 알려줘')">서울 날씨</button>
|
||||
<button class="soft" onclick="setMessage('부산 날씨 알려줘')">부산 날씨</button>
|
||||
<button class="soft" onclick="setMessage('달러 환율 알려줘')">달러 환율</button>
|
||||
<button class="soft" onclick="setMessage('대한민국 공휴일 조회해줘')">공휴일 조회</button>
|
||||
<button class="soft" onclick="setMessage('서울 좌표 조회해줘')">서울 좌표</button>
|
||||
<button class="soft" onclick="setMessage('대한민국 국가 정보 조회해줘')">국가 정보</button>
|
||||
<button class="soft" onclick="setMessage('고객 조회해줘')">고객 조회</button>
|
||||
<button class="soft" onclick="setMessage('주문 상태 조회해줘')">주문 상태</button>
|
||||
<button class="soft" onclick="setMessage('고객 문의 티켓 생성해줘')">티켓 생성</button>
|
||||
<button class="soft" onclick="setMessage('메타 공통코드 조회해줘')">메타 공통코드</button>
|
||||
<button class="soft" onclick="setMessage('메타 테이블 조회해줘')">메타 테이블</button>
|
||||
<button class="soft" onclick="setMessage('템플릿 다운로드 URL 알려줘')">템플릿 URL</button>
|
||||
<button class="soft" onclick="setMessage('SOL 의뢰서 목록 조회해줘')">SOL 목록</button>
|
||||
<button class="soft" onclick="setMessage('SOL 의뢰서 상세 조회해줘')">SOL 상세</button>
|
||||
<button class="soft" onclick="setMessage('보험금 청구 처리해줘')">보험금 청구</button>
|
||||
<button class="soft" onclick="setMessage('가입설계 한도 조회해줘')">가입설계 한도</button>
|
||||
<button class="soft" onclick="setMessage('CUS 달러 환율 조회해줘')">CUS 환율</button>
|
||||
<button class="soft" onclick="setMessage('CUS 서울 날씨 조회해줘')">CUS 날씨</button>
|
||||
<button class="soft" onclick="setMessage('오늘의 명언 알려줘')">오늘의 명언</button>
|
||||
<button class="soft" onclick="setMessage('TOOL 파트 구성원 조회해줘')">TOOL 구성원</button>
|
||||
</div>
|
||||
<div class="answer" id="answer">Agent 응답 대기 중</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>Tool 직접 호출</strong></summary>
|
||||
<label for="toolPreset">호출할 Tool</label>
|
||||
<select id="toolPreset" onchange="setToolPreset(this.value)" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
|
||||
<option value="external.weather_lookup">날씨 조회</option>
|
||||
<option value="external.exchange_rate">환율 조회</option>
|
||||
<option value="external.public_holiday_lookup">공휴일 조회</option>
|
||||
<option value="external.geocoding_lookup">도시 좌표 조회</option>
|
||||
<option value="external.country_info_lookup">국가 정보 조회</option>
|
||||
<option value="business.customer_search">고객 검색</option>
|
||||
<option value="business.order_status">주문 상태 조회</option>
|
||||
<option value="business.ticket_create">지원 티켓 생성(승인 필요)</option>
|
||||
<option value="cmm_comcode_lookup">메타 공통코드 조회</option>
|
||||
<option value="cmm_customer_tool">고객 통합 안내이력 조회</option>
|
||||
<option value="cmm_meta_table">메타 테이블 조회</option>
|
||||
<option value="cmm_template_url">템플릿 URL 조회</option>
|
||||
<option value="sol_request_list">SOL 의뢰서 목록</option>
|
||||
<option value="sol_request_detail">SOL 의뢰서 상세</option>
|
||||
<option value="ins_insurance_processor">보험금 청구 처리</option>
|
||||
<option value="oth_onnba3011_call">가입설계 한도 조회</option>
|
||||
<option value="smp_exchange_inquiry">CUS 환율 조회</option>
|
||||
<option value="smp_weather_inquiry">CUS 날씨 조회</option>
|
||||
<option value="smp_quote_daily">오늘의 명언</option>
|
||||
<option value="smp_team_list">TOOL 파트 구성원</option>
|
||||
</select>
|
||||
<div class="manual-grid">
|
||||
<div>
|
||||
<label for="toolName">Tool Name</label>
|
||||
<input id="toolName" value="external.weather_lookup">
|
||||
</div>
|
||||
<div>
|
||||
<label for="arguments">Arguments JSON</label>
|
||||
<textarea id="arguments">{
|
||||
"city": "Seoul",
|
||||
"timezone": "Asia/Seoul"
|
||||
}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="callTool()">Tools/Call</button>
|
||||
<button class="soft" onclick="setWeather()">Weather Args</button>
|
||||
<button class="soft" onclick="setExchange()">Exchange Args</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>상세 응답 보기</strong></summary>
|
||||
<pre id="output">Waiting...</pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const output = document.getElementById("output");
|
||||
const config = document.getElementById("config");
|
||||
const answer = document.getElementById("answer");
|
||||
|
||||
async function callApi(method, url, body) {
|
||||
output.textContent = "Requesting...";
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
output.textContent = JSON.stringify(data, null, 2);
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || data.message || data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
output.textContent = JSON.stringify({error: error.message}, null, 2);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const data = await callApi("GET", "/api/config");
|
||||
document.getElementById("routeKey").value = data.defaultRouteKey || "cus";
|
||||
config.innerHTML = `MCP ${data.mcpEndpointUrl}<br>Route MCP ${data.defaultRoutedMcpEndpointUrl}<br>Manifest ${data.toolManifestUrl}`;
|
||||
}
|
||||
|
||||
async function loadRegistry() {
|
||||
const bundles = await callApi("GET", "/api/portal/bundles");
|
||||
window.portalBundles = bundles;
|
||||
}
|
||||
|
||||
function openNewBundle() {
|
||||
clearBundleForm();
|
||||
document.getElementById("bundleEditor").open = true;
|
||||
}
|
||||
|
||||
function closeBundleEditor() {
|
||||
document.getElementById("bundleEditor").open = false;
|
||||
}
|
||||
|
||||
function clearBundleForm() {
|
||||
["bundleId", "toolServerName", "manifestUrl", "baseUrl", "namePrefix", "apiKey"]
|
||||
.forEach(id => document.getElementById(id).value = "");
|
||||
document.getElementById("bundleId").readOnly = false;
|
||||
document.getElementById("pollInterval").value = 60;
|
||||
document.getElementById("toolEndpoints").value = "{}";
|
||||
document.getElementById("bundleEnabled").checked = true;
|
||||
}
|
||||
|
||||
async function saveBundle() {
|
||||
const idInput = document.getElementById("bundleId");
|
||||
const payload = {
|
||||
bundleId: idInput.value.trim(),
|
||||
toolServerName: document.getElementById("toolServerName").value.trim(),
|
||||
manifestUrl: document.getElementById("manifestUrl").value.trim(),
|
||||
baseUrl: document.getElementById("baseUrl").value.trim(),
|
||||
namePrefix: document.getElementById("namePrefix").value.trim(),
|
||||
enabled: document.getElementById("bundleEnabled").checked,
|
||||
manifestPollIntervalSeconds: Number(document.getElementById("pollInterval").value),
|
||||
apiKey: document.getElementById("apiKey").value,
|
||||
toolEndpoints: JSON.parse(document.getElementById("toolEndpoints").value || "{}")
|
||||
};
|
||||
const url = idInput.readOnly
|
||||
? `/api/portal/bundles/${encodeURIComponent(payload.bundleId)}`
|
||||
: "/api/portal/bundles";
|
||||
await callApi(idInput.readOnly ? "PUT" : "POST", url, payload);
|
||||
await loadRegistry();
|
||||
closeBundleEditor();
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[char]);
|
||||
}
|
||||
|
||||
function escapeJs(value) {
|
||||
return String(value).replace(/[\\']/g, "\\$&");
|
||||
}
|
||||
|
||||
async function bumpRevision() {
|
||||
const data = await callApi("POST", `/api/portal/registry/${selectedRoute()}/revision`);
|
||||
await loadRegistry();
|
||||
output.textContent = JSON.stringify({
|
||||
message: "Portal registry revision bumped",
|
||||
routeKey: data.routeKey,
|
||||
registryRevision: data.registryRevision
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function renderGroup(title, rows) {
|
||||
return `<div><h3>${title}</h3>${rows.map(renderItem).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderItem(row) {
|
||||
const title = row.displayName || row.serviceKey || row.routeKey;
|
||||
const entries = Object.entries(row)
|
||||
.map(([key, value]) => `<div>${key}</div><div>${value}</div>`)
|
||||
.join("");
|
||||
return `<div class="item"><div class="item-title">${title} <span class="badge">${row.status || row.source}</span></div><div class="kv">${entries}</div></div>`;
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const message = document.getElementById("message").value.trim();
|
||||
answer.textContent = "Agent is selecting a tool...";
|
||||
const data = await callApi("POST", "/api/agent/chat", {message});
|
||||
answer.innerHTML = `<strong>${data.answer}</strong><br><br>Agent route 결정: ${data.routeKey}<br>판단: ${data.routeDecision}<br>MCP: ${data.mcpEndpointUrl}<br>Tool: ${data.selectedTool}<br>인자: ${JSON.stringify(data.arguments)}`;
|
||||
}
|
||||
|
||||
async function callTool() {
|
||||
const name = document.getElementById("toolName").value.trim();
|
||||
const args = JSON.parse(document.getElementById("arguments").value);
|
||||
const routeKey = name.startsWith("business.") ? "business"
|
||||
: /^(cmm|ins|oth|smp|sol)_/.test(name) ? "cus" : "external";
|
||||
await callApi("POST", `/api/mcp/${routeKey}/tools/call`, {name, arguments: args});
|
||||
}
|
||||
|
||||
function setToolPreset(name) {
|
||||
const presets = {
|
||||
"external.weather_lookup": {city: "Seoul", timezone: "Asia/Seoul"},
|
||||
"external.exchange_rate": {from: "USD", to: "KRW"},
|
||||
"external.public_holiday_lookup": {countryCode: "KR", year: new Date().getFullYear()},
|
||||
"external.geocoding_lookup": {city: "Seoul", language: "ko"},
|
||||
"external.country_info_lookup": {countryCode: "KR"},
|
||||
"business.customer_search": {keyword: "C-1001"},
|
||||
"business.order_status": {orderId: "O-9001"},
|
||||
"business.ticket_create": {
|
||||
title: "Portal test ticket",
|
||||
priority: "normal",
|
||||
description: "Created from the Portal Tool test screen"
|
||||
},
|
||||
"cmm_comcode_lookup": {groupCode: "GRP_COMM_CD", useYn: "Y"},
|
||||
"cmm_customer_tool": {csNo: "000000000001"},
|
||||
"cmm_meta_table": {tableName: "TB_CUST_BAS", owner: "DAPADM"},
|
||||
"cmm_template_url": {templateId: "TPL_001"},
|
||||
"sol_request_list": {status: "진행중", period: "1개월", target: "나의 업무"},
|
||||
"sol_request_detail": {srId: "SR-001"},
|
||||
"ins_insurance_processor": {claimNumber: "CLM20230001", claimAmount: 1500000, claimDate: "2026-08-12"},
|
||||
"oth_onnba3011_call": {dalScCd: "1", cstSucoRltyCd: "01", csNo: "000000000001"},
|
||||
"smp_exchange_inquiry": {currencyCode: "USD"},
|
||||
"smp_weather_inquiry": {city: "서울"},
|
||||
"smp_quote_daily": {category: "속담"},
|
||||
"smp_team_list": {teamName: "TOOL"}
|
||||
};
|
||||
document.getElementById("toolName").value = name;
|
||||
document.getElementById("arguments").value = JSON.stringify(presets[name] || {}, null, 2);
|
||||
}
|
||||
|
||||
function selectedRoute() {
|
||||
const route = document.getElementById("routeKey").value.trim();
|
||||
return route || "cus";
|
||||
}
|
||||
|
||||
function mcpAction(action) {
|
||||
return `/api/mcp/${selectedRoute()}/${action}`;
|
||||
}
|
||||
|
||||
async function loadMcpTools() {
|
||||
const data = await callApi("POST", mcpTestAction("tools/list"));
|
||||
const prefix = document.getElementById("mcpToolServerFilter").value;
|
||||
const tools = (((data || {}).body || {}).result || {}).tools || [];
|
||||
const filtered = prefix === "all" || prefix === "cus"
|
||||
? tools : tools.filter(tool => tool.name.startsWith(prefix));
|
||||
const target = document.getElementById("mcpToolList");
|
||||
target.innerHTML = filtered.length ? filtered.map(tool => `
|
||||
<div class="item">
|
||||
<div class="item-title">${escapeHtml(tool.title || tool.name)}</div>
|
||||
<div class="kv">
|
||||
<div>name</div><div>${escapeHtml(tool.name)}</div>
|
||||
<div>설명</div><div>${escapeHtml(tool.description || "-")}</div>
|
||||
<div>유형</div><div>${tool.annotations && tool.annotations.readOnlyHint ? "READ" : "WRITE"}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="soft" onclick="prepareToolCall('${escapeJs(tool.name)}')">이 Tool 호출</button>
|
||||
</div>
|
||||
</div>`).join("") : `<div class="header-meta" style="text-align:left;color:var(--muted)">선택한 서버의 Tool이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
function mcpTestAction(action) {
|
||||
const filter = document.getElementById("mcpToolServerFilter").value;
|
||||
const routeKey = filter === "business." ? "business"
|
||||
: filter === "cus" ? "cus" : "external";
|
||||
return `/api/mcp/${routeKey}/${action}`;
|
||||
}
|
||||
|
||||
function prepareToolCall(name) {
|
||||
setToolPreset(name);
|
||||
document.getElementById("toolPreset").value = name;
|
||||
document.getElementById("toolPreset").closest("details").open = true;
|
||||
document.getElementById("toolPreset").scrollIntoView({behavior: "smooth", block: "center"});
|
||||
}
|
||||
|
||||
function setMessage(value) {
|
||||
document.getElementById("message").value = value;
|
||||
}
|
||||
|
||||
function setWeather() {
|
||||
document.getElementById("toolName").value = "external.weather_lookup";
|
||||
document.getElementById("arguments").value = JSON.stringify({
|
||||
city: "Seoul",
|
||||
timezone: "Asia/Seoul"
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function setExchange() {
|
||||
document.getElementById("toolName").value = "external.exchange_rate";
|
||||
document.getElementById("arguments").value = JSON.stringify({
|
||||
from: "USD",
|
||||
to: "KRW"
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
loadConfig().then(loadRegistry).catch(error => {
|
||||
config.textContent = "Configuration load failed";
|
||||
output.textContent = JSON.stringify({error: error.message}, null, 2);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.5.11'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'com.example'
|
||||
version = '0.1.0'
|
||||
description = 'agent-test-backend'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
potal/agent-test-backend/gradlew
vendored
251
potal/agent-test-backend/gradlew
vendored
@@ -1,251 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
potal/agent-test-backend/gradlew.bat
vendored
94
potal/agent-test-backend/gradlew.bat
vendored
@@ -1,94 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1,15 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'agent-test-backend'
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
@EnableScheduling
|
||||
public class AgentTestBackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AgentTestBackendApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "agent-test")
|
||||
public record AgentTestProperties(Mcp mcp, ToolServer toolServer, Portal portal) {
|
||||
|
||||
public record Mcp(String endpointUrl, String protocolVersion) {
|
||||
}
|
||||
|
||||
public record ToolServer(String manifestUrl, String apiKey) {
|
||||
}
|
||||
|
||||
public record Portal(long registryRevision, String routeKey, String toolServiceDomain) {
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.time.Year;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class McpProxyController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(McpProxyController.class);
|
||||
private static final String MCP_SESSION_ID_HEADER = "Mcp-Session-Id";
|
||||
private static final String MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version";
|
||||
private static final String TOOL_SERVER_API_KEY_HEADER = "X-Tool-Server-API-Key";
|
||||
|
||||
private final AgentTestProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RestClient restClient;
|
||||
private final AtomicLong ids = new AtomicLong(1);
|
||||
private final PortalBundleService portalBundles;
|
||||
private final AtomicReference<String> latestSessionId = new AtomicReference<>();
|
||||
|
||||
public McpProxyController(
|
||||
AgentTestProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
RestClient.Builder builder,
|
||||
PortalBundleService portalBundles) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restClient = builder.build();
|
||||
this.portalBundles = portalBundles;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> config() {
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("mcpEndpointUrl", properties.mcp().endpointUrl());
|
||||
config.put("mcpProtocolVersion", properties.mcp().protocolVersion());
|
||||
config.put("toolManifestUrl", properties.toolServer().manifestUrl());
|
||||
config.put("defaultRouteKey", defaultRouteKey());
|
||||
config.put("defaultRoutedMcpEndpointUrl", mcpEndpointUrl(defaultRouteKey()));
|
||||
config.put("latestSessionId", latestSessionId.get());
|
||||
return config;
|
||||
}
|
||||
|
||||
@GetMapping("/registry")
|
||||
public Map<String, Object> registry() {
|
||||
String routeKey = defaultRouteKey();
|
||||
return portalBundles.screenRegistry(routeKey, mcpEndpointUrl(routeKey));
|
||||
}
|
||||
|
||||
@GetMapping("/portal/registry")
|
||||
public Map<String, Object> portalRegistry(HttpServletRequest request) {
|
||||
log.info("Inbound portal aggregate registry request: method={}, uri={}, remoteAddress={}, userAgent={}, accept={}",
|
||||
request.getMethod(), request.getRequestURI(), request.getRemoteAddr(),
|
||||
request.getHeader("User-Agent"), request.getHeader("Accept"));
|
||||
|
||||
Map<String, Object> registry = portalBundles.portalRegistry();
|
||||
log.info("Portal aggregate registry response: registryRevision={}, routes={}",
|
||||
registry.get("registryRevision"), registry.get("routes"));
|
||||
return registry;
|
||||
}
|
||||
|
||||
@GetMapping("/portal/registry/{routeKey}")
|
||||
public Map<String, Object> portalRegistry(
|
||||
@PathVariable("routeKey") String routeKey,
|
||||
HttpServletRequest request) {
|
||||
log.info("Inbound portal registry request: method={}, uri={}, remoteAddress={}, userAgent={}, accept={}",
|
||||
request.getMethod(), request.getRequestURI(), request.getRemoteAddr(),
|
||||
request.getHeader("User-Agent"), request.getHeader("Accept"));
|
||||
|
||||
Map<String, Object> registry = portalBundles.portalRegistry(routeKey);
|
||||
log.info("Portal registry response: routeKey={}, registryRevision={}, toolServices={}",
|
||||
routeKey, registry.get("registryRevision"), registry.get("toolServices"));
|
||||
return registry;
|
||||
}
|
||||
|
||||
@PostMapping("/portal/registry/{routeKey}/revision")
|
||||
public Map<String, Object> bumpPortalRegistryRevision(@PathVariable("routeKey") String routeKey) {
|
||||
long nextRevision = portalBundles.bumpRevision();
|
||||
log.info("Portal registry revision changed: routeKey={}, registryRevision={}", routeKey, nextRevision);
|
||||
return Map.of(
|
||||
"routeKey", routeKey,
|
||||
"registryRevision", nextRevision);
|
||||
}
|
||||
|
||||
@GetMapping("/tool-manifest")
|
||||
public Map<String, Object> toolManifest() {
|
||||
log.info("Outbound tool-server request: method=GET, uri={}, headers={{{}={}}}",
|
||||
properties.toolServer().manifestUrl(), TOOL_SERVER_API_KEY_HEADER, masked());
|
||||
ResponseEntity<JsonNode> response = restClient.get()
|
||||
.uri(properties.toolServer().manifestUrl())
|
||||
.header(TOOL_SERVER_API_KEY_HEADER, properties.toolServer().apiKey())
|
||||
.retrieve()
|
||||
.toEntity(JsonNode.class);
|
||||
log.info("Inbound tool-server response: status={}, body={}",
|
||||
response.getStatusCode().value(), response.getBody());
|
||||
return response("tool-manifest", response);
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/initialize")
|
||||
public Map<String, Object> initialize() {
|
||||
return initialize(properties.portal().routeKey());
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/{routeKey}/initialize")
|
||||
public Map<String, Object> initialize(@PathVariable("routeKey") String routeKey) {
|
||||
Map<String, Object> params = Map.of(
|
||||
"protocolVersion", properties.mcp().protocolVersion(),
|
||||
"capabilities", Map.of(),
|
||||
"clientInfo", Map.of(
|
||||
"name", "agent-test-backend",
|
||||
"version", "0.1.0"));
|
||||
ResponseEntity<JsonNode> response = postMcp(routeKey, jsonRpc("initialize", params), false);
|
||||
String sessionId = response.getHeaders().getFirst(MCP_SESSION_ID_HEADER);
|
||||
if (sessionId != null && !sessionId.isBlank()) {
|
||||
latestSessionId.set(sessionId);
|
||||
}
|
||||
return response("initialize", response);
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/initialized")
|
||||
public Map<String, Object> initialized() {
|
||||
return initialized(properties.portal().routeKey());
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/{routeKey}/initialized")
|
||||
public Map<String, Object> initialized(@PathVariable("routeKey") String routeKey) {
|
||||
ResponseEntity<JsonNode> response = postMcp(routeKey, notification("notifications/initialized"), true);
|
||||
return response("notifications/initialized", response);
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/tools/list")
|
||||
public Map<String, Object> toolsList() {
|
||||
return toolsList(properties.portal().routeKey());
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/{routeKey}/tools/list")
|
||||
public Map<String, Object> toolsList(@PathVariable("routeKey") String routeKey) {
|
||||
ResponseEntity<JsonNode> response = postMcp(routeKey, jsonRpc("tools/list", Map.of()), true);
|
||||
return response("tools/list", response);
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/tools/call")
|
||||
public Map<String, Object> toolsCall(@RequestBody ToolCallRequest request) {
|
||||
return callTool(routeKeyForTool(request.name()), request.name(), request.arguments() == null ? Map.of() : request.arguments());
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/{routeKey}/tools/call")
|
||||
public Map<String, Object> toolsCall(
|
||||
@PathVariable("routeKey") String routeKey,
|
||||
@RequestBody ToolCallRequest request) {
|
||||
return callTool(routeKey, request.name(), request.arguments() == null ? Map.of() : request.arguments());
|
||||
}
|
||||
|
||||
@PostMapping("/agent/chat")
|
||||
public Map<String, Object> chat(@RequestBody ChatRequest request) {
|
||||
PlannedTool plannedTool = plan(request.message());
|
||||
String routeKey = plannedTool.routeKey();
|
||||
Map<String, Object> toolCall = callTool(routeKey, plannedTool.name(), plannedTool.arguments());
|
||||
JsonNode body = objectMapper.valueToTree(toolCall.get("body"));
|
||||
JsonNode toolPayload = firstToolText(body);
|
||||
if (body.path("result").path("isError").asBoolean(false)) {
|
||||
String toolError = toolPayload.path("text").asText("도구 호출에 실패했습니다.");
|
||||
toolPayload = objectMapper.createObjectNode().put("error", toolError);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("routeKey", routeKey);
|
||||
result.put("mcpEndpointUrl", mcpEndpointUrl(routeKey));
|
||||
result.put("message", request.message());
|
||||
result.put("selectedTool", plannedTool.name());
|
||||
result.put("routeDecision", "%s prefix Tool은 %s route로 전송".formatted(
|
||||
toolPrefix(plannedTool.name()), routeKey));
|
||||
result.put("arguments", plannedTool.arguments());
|
||||
result.put("answer", answer(plannedTool.name(), toolPayload));
|
||||
result.put("toolResult", toolPayload);
|
||||
result.put("rawMcpResponse", toolCall);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> callTool(String routeKey, String name, Map<String, Object> arguments) {
|
||||
Map<String, Object> params = Map.of("name", name, "arguments", arguments);
|
||||
ResponseEntity<JsonNode> response = postMcp(routeKey, jsonRpc("tools/call", params), true);
|
||||
return response("tools/call", response);
|
||||
}
|
||||
|
||||
private PlannedTool plan(String message) {
|
||||
return planRequest(message);
|
||||
}
|
||||
|
||||
static PlannedTool planRequest(String message) {
|
||||
String normalized = message == null ? "" : message.toLowerCase(Locale.ROOT);
|
||||
if (normalized.contains("메타 공통코드")) {
|
||||
return new PlannedTool("cus", "cmm_comcode_lookup",
|
||||
Map.of("groupCode", "GRP_COMM_CD", "useYn", "Y"));
|
||||
}
|
||||
if (normalized.contains("메타 테이블")) {
|
||||
return new PlannedTool("cus", "cmm_meta_table",
|
||||
Map.of("tableName", "TB_CUST_BAS", "owner", "DAPADM"));
|
||||
}
|
||||
if (normalized.contains("템플릿")) {
|
||||
return new PlannedTool("cus", "cmm_template_url", Map.of("templateId", "TPL_001"));
|
||||
}
|
||||
if (normalized.contains("sol 의뢰서 상세") || normalized.contains("sol 상세")) {
|
||||
return new PlannedTool("cus", "sol_request_detail", Map.of("srId", "SR-001"));
|
||||
}
|
||||
if (normalized.contains("sol 의뢰서") || normalized.contains("sol 목록")) {
|
||||
return new PlannedTool("cus", "sol_request_list",
|
||||
Map.of("status", "진행중", "period", "1개월", "target", "나의 업무"));
|
||||
}
|
||||
if (normalized.contains("보험금 청구")) {
|
||||
return new PlannedTool("cus", "ins_insurance_processor", Map.of(
|
||||
"claimNumber", "CLM20230001", "claimAmount", 1500000, "claimDate", "2026-08-12"));
|
||||
}
|
||||
if (normalized.contains("가입설계 한도") || normalized.contains("onnba3011")) {
|
||||
return new PlannedTool("cus", "oth_onnba3011_call", Map.of(
|
||||
"dalScCd", "1", "cstSucoRltyCd", "01", "csNo", "000000000001"));
|
||||
}
|
||||
if (normalized.contains("cus") && (normalized.contains("환율") || normalized.contains("exchange"))) {
|
||||
return new PlannedTool("cus", "smp_exchange_inquiry", Map.of("currencyCode", "USD"));
|
||||
}
|
||||
if (normalized.contains("cus") && (normalized.contains("날씨") || normalized.contains("weather"))) {
|
||||
return new PlannedTool("cus", "smp_weather_inquiry", Map.of("city", "서울"));
|
||||
}
|
||||
if (normalized.contains("오늘의 명언") || normalized.contains("명언")) {
|
||||
return new PlannedTool("cus", "smp_quote_daily", Map.of("category", "속담"));
|
||||
}
|
||||
if (normalized.contains("tool 파트") || normalized.contains("툴 파트") || normalized.contains("파트 구성원")) {
|
||||
return new PlannedTool("cus", "smp_team_list", Map.of("teamName", "TOOL"));
|
||||
}
|
||||
if (normalized.contains("공휴일") || normalized.contains("휴일") || normalized.contains("holiday")) {
|
||||
return new PlannedTool("external", "external.public_holiday_lookup", Map.of(
|
||||
"countryCode", "KR",
|
||||
"year", Year.now().getValue()));
|
||||
}
|
||||
if ((normalized.contains("고객") || normalized.contains("customer"))
|
||||
&& !normalized.contains("티켓") && !normalized.contains("ticket")) {
|
||||
return new PlannedTool("business", "business.customer_search", Map.of("keyword", "C-1001"));
|
||||
}
|
||||
if (normalized.contains("주문") || normalized.contains("order")) {
|
||||
return new PlannedTool("business", "business.order_status", Map.of("orderId", "O-9001"));
|
||||
}
|
||||
if (normalized.contains("티켓") || normalized.contains("ticket")) {
|
||||
return new PlannedTool("business", "business.ticket_create", Map.of(
|
||||
"title", "Portal test ticket",
|
||||
"priority", "normal",
|
||||
"description", "Created from the Portal Agent test screen"));
|
||||
}
|
||||
if (normalized.contains("좌표") || normalized.contains("지오코딩") || normalized.contains("geocoding")) {
|
||||
return new PlannedTool("external", "external.geocoding_lookup", Map.of("city", "Seoul", "language", "ko"));
|
||||
}
|
||||
if (normalized.contains("국가") || normalized.contains("나라") || normalized.contains("country")) {
|
||||
return new PlannedTool("external", "external.country_info_lookup", Map.of("countryCode", "KR"));
|
||||
}
|
||||
if (normalized.contains("환율") || normalized.contains("달러") || normalized.contains("usd")
|
||||
|| normalized.contains("exchange")) {
|
||||
return new PlannedTool("external", "external.exchange_rate", Map.of("from", "USD", "to", "KRW"));
|
||||
}
|
||||
return new PlannedTool("external", "external.weather_lookup", Map.of(
|
||||
"city", city(normalized),
|
||||
"timezone", "Asia/Seoul"));
|
||||
}
|
||||
|
||||
private static String toolPrefix(String toolName) {
|
||||
int dot = toolName.indexOf('.');
|
||||
if (dot > 0) {
|
||||
return toolName.substring(0, dot);
|
||||
}
|
||||
int underscore = toolName.indexOf('_');
|
||||
return underscore > 0 ? toolName.substring(0, underscore) : toolName;
|
||||
}
|
||||
|
||||
private static String city(String message) {
|
||||
if (message.contains("부산") || message.contains("busan")) {
|
||||
return "Busan";
|
||||
}
|
||||
if (message.contains("대구") || message.contains("daegu")) {
|
||||
return "Daegu";
|
||||
}
|
||||
if (message.contains("인천") || message.contains("incheon")) {
|
||||
return "Incheon";
|
||||
}
|
||||
return "Seoul";
|
||||
}
|
||||
|
||||
private JsonNode firstToolText(JsonNode mcpBody) {
|
||||
JsonNode content = mcpBody.path("result").path("content");
|
||||
if (!content.isArray() || content.isEmpty()) {
|
||||
return mcpBody;
|
||||
}
|
||||
String text = content.get(0).path("text").asText("");
|
||||
if (text.isBlank()) {
|
||||
return content.get(0);
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(text);
|
||||
} catch (Exception ignored) {
|
||||
return objectMapper.createObjectNode().put("text", text);
|
||||
}
|
||||
}
|
||||
|
||||
static String answer(String toolName, JsonNode payload) {
|
||||
boolean directToolResult = !payload.has("success") && !payload.has("error");
|
||||
if (!directToolResult && !payload.path("success").asBoolean(false)) {
|
||||
String error = payload.path("error").asText();
|
||||
if (error.isBlank()) {
|
||||
error = payload.path("text").asText("도구 호출에 실패했습니다.");
|
||||
}
|
||||
return "도구 실행이 실패했습니다: " + error;
|
||||
}
|
||||
JsonNode data = directToolResult ? payload : payload.path("data");
|
||||
if ("external.public_holiday_lookup".equals(toolName)) {
|
||||
JsonNode holidays = data.path("holidays");
|
||||
List<String> preview = new java.util.ArrayList<>();
|
||||
if (holidays.isArray()) {
|
||||
for (int index = 0; index < Math.min(holidays.size(), 5); index++) {
|
||||
JsonNode holiday = holidays.get(index);
|
||||
preview.add("%s %s".formatted(
|
||||
holiday.path("date").asText(),
|
||||
holiday.path("localName").asText(holiday.path("name").asText())));
|
||||
}
|
||||
}
|
||||
return "%s년 대한민국 공휴일은 총 %s일입니다.%s".formatted(
|
||||
data.path("year").asText(String.valueOf(Year.now().getValue())),
|
||||
holidays.isArray() ? holidays.size() : 0,
|
||||
preview.isEmpty() ? "" : " 주요 공휴일: " + String.join(", ", preview));
|
||||
}
|
||||
if (toolName.startsWith("business.")) {
|
||||
return "%s 실행 결과: %s".formatted(toolName, data.toString());
|
||||
}
|
||||
if (isCusTool(toolName)) {
|
||||
return "%s 실행 결과: %s".formatted(toolName, data.toString());
|
||||
}
|
||||
if ("external.geocoding_lookup".equals(toolName)) {
|
||||
return "도시 좌표 조회 결과: " + data.path("results").toString();
|
||||
}
|
||||
if ("external.country_info_lookup".equals(toolName)) {
|
||||
return "국가 정보 조회 결과: " + data.path("countries").toString();
|
||||
}
|
||||
if ("external.exchange_rate".equals(toolName)) {
|
||||
return "%s 기준 %s/%s 환율은 %s입니다.".formatted(
|
||||
data.path("date").asText("현재"),
|
||||
data.path("base").asText("USD"),
|
||||
data.path("target").asText("KRW"),
|
||||
data.path("rate").asText());
|
||||
}
|
||||
return "%s 현재 기온은 %s도이고 풍속은 %s입니다.".formatted(
|
||||
data.path("city").asText("해당 지역"),
|
||||
data.path("temperature").asText(),
|
||||
data.path("windSpeed").asText());
|
||||
}
|
||||
|
||||
private static boolean isCusTool(String toolName) {
|
||||
return toolName.startsWith("cmm_") || toolName.startsWith("ins_")
|
||||
|| toolName.startsWith("oth_") || toolName.startsWith("smp_")
|
||||
|| toolName.startsWith("sol_");
|
||||
}
|
||||
|
||||
private ResponseEntity<JsonNode> postMcp(String routeKey, Map<String, Object> payload, boolean includeProtocolHeaders) {
|
||||
String sessionId = includeProtocolHeaders ? latestSessionId.get() : null;
|
||||
String endpointUrl = mcpEndpointUrl(routeKey);
|
||||
Map<String, Object> headers = new LinkedHashMap<>();
|
||||
headers.put("Content-Type", MediaType.APPLICATION_JSON_VALUE);
|
||||
if (includeProtocolHeaders) {
|
||||
headers.put(MCP_PROTOCOL_VERSION_HEADER, properties.mcp().protocolVersion());
|
||||
if (sessionId != null && !sessionId.isBlank()) {
|
||||
headers.put(MCP_SESSION_ID_HEADER, sessionId);
|
||||
}
|
||||
}
|
||||
log.info("Outbound MCP request: method=POST, uri={}, headers={}, body={}",
|
||||
endpointUrl, headers, payload);
|
||||
|
||||
RestClient.RequestBodySpec spec = restClient.post()
|
||||
.uri(endpointUrl)
|
||||
.contentType(MediaType.APPLICATION_JSON);
|
||||
if (includeProtocolHeaders) {
|
||||
spec.header(MCP_PROTOCOL_VERSION_HEADER, properties.mcp().protocolVersion());
|
||||
if (sessionId != null && !sessionId.isBlank()) {
|
||||
spec.header(MCP_SESSION_ID_HEADER, sessionId);
|
||||
}
|
||||
}
|
||||
ResponseEntity<JsonNode> response = spec.body(payload).retrieve().toEntity(JsonNode.class);
|
||||
log.info("Inbound MCP response: status={}, headers={{{}={}}}, body={}",
|
||||
response.getStatusCode().value(), MCP_SESSION_ID_HEADER,
|
||||
response.getHeaders().getFirst(MCP_SESSION_ID_HEADER), response.getBody());
|
||||
return response;
|
||||
}
|
||||
|
||||
private String mcpEndpointUrl(String routeKey) {
|
||||
String baseUrl = properties.mcp().endpointUrl().replaceAll("/+$", "");
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
return normalizedRouteKey.isBlank() ? baseUrl : baseUrl + "/" + normalizedRouteKey;
|
||||
}
|
||||
|
||||
private String normalizeRouteKey(String routeKey) {
|
||||
if (routeKey == null || routeKey.isBlank()) {
|
||||
return defaultRouteKey();
|
||||
}
|
||||
return routeKey.trim();
|
||||
}
|
||||
|
||||
private String defaultRouteKey() {
|
||||
String configured = properties.portal().routeKey();
|
||||
return configured == null || configured.isBlank() ? "cus" : configured.trim();
|
||||
}
|
||||
|
||||
private String routeKeyForTool(String toolName) {
|
||||
if (toolName == null || toolName.isBlank()) {
|
||||
return defaultRouteKey();
|
||||
}
|
||||
if (toolName.startsWith("business.")) {
|
||||
return "business";
|
||||
}
|
||||
if (isCusTool(toolName)) {
|
||||
return "cus";
|
||||
}
|
||||
return "external";
|
||||
}
|
||||
private String masked() {
|
||||
return properties.toolServer().apiKey() == null || properties.toolServer().apiKey().isBlank()
|
||||
? "<empty>"
|
||||
: "********";
|
||||
}
|
||||
|
||||
private Map<String, Object> jsonRpc(String method, Map<String, Object> params) {
|
||||
return Map.of(
|
||||
"jsonrpc", "2.0",
|
||||
"id", ids.getAndIncrement(),
|
||||
"method", method,
|
||||
"params", params);
|
||||
}
|
||||
|
||||
private Map<String, Object> notification(String method) {
|
||||
return Map.of(
|
||||
"jsonrpc", "2.0",
|
||||
"method", method,
|
||||
"params", Map.of());
|
||||
}
|
||||
|
||||
private Map<String, Object> response(String action, ResponseEntity<JsonNode> response) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("action", action);
|
||||
result.put("httpStatus", response.getStatusCode().value());
|
||||
result.put("mcpSessionId", response.getHeaders().getFirst(MCP_SESSION_ID_HEADER));
|
||||
result.put("body", response.getBody() == null ? objectMapper.createObjectNode() : response.getBody());
|
||||
return result;
|
||||
}
|
||||
|
||||
public record ToolCallRequest(String name, Map<String, Object> arguments) {
|
||||
}
|
||||
|
||||
public record ChatRequest(String message) {
|
||||
}
|
||||
|
||||
record PlannedTool(String routeKey, String name, Map<String, Object> arguments) {
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = PortalBundleController.class)
|
||||
public class PortalApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<Map<String, Object>> handle(ResponseStatusException error) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", error.getStatusCode().value());
|
||||
body.put("error", error.getStatusCode().toString());
|
||||
body.put("message", error.getReason() == null ? "Request failed" : error.getReason());
|
||||
return ResponseEntity.status(error.getStatusCode()).body(body);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import com.example.agenttest.PortalBundleService.BundleRequest;
|
||||
import com.example.agenttest.PortalBundleService.BundleView;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/portal/bundles")
|
||||
public class PortalBundleController {
|
||||
|
||||
private final PortalBundleService bundles;
|
||||
|
||||
public PortalBundleController(PortalBundleService bundles) {
|
||||
this.bundles = bundles;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<BundleView> list() {
|
||||
return bundles.list();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public BundleView create(@RequestBody BundleRequest request) {
|
||||
return bundles.create(request);
|
||||
}
|
||||
|
||||
@PutMapping("/{bundleId}")
|
||||
public BundleView update(
|
||||
@PathVariable("bundleId") String bundleId,
|
||||
@RequestBody BundleRequest request) {
|
||||
return bundles.update(bundleId, request);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class PortalBundleService {
|
||||
|
||||
private static final Pattern BUNDLE_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}");
|
||||
private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}");
|
||||
|
||||
private final Map<String, BundleState> bundles = new ConcurrentHashMap<>();
|
||||
private final AtomicLong registryRevision;
|
||||
|
||||
public PortalBundleService(AgentTestProperties properties, RestClient.Builder builder) {
|
||||
this.registryRevision = new AtomicLong(properties.portal().registryRevision());
|
||||
String sharedApiKey = properties.toolServer().apiKey();
|
||||
putSeed(new BundleDefinition(
|
||||
"external-tools", "External Tool Server",
|
||||
properties.toolServer().manifestUrl(), properties.portal().toolServiceDomain(),
|
||||
"external.", true, 10, sharedApiKey,
|
||||
Map.of(
|
||||
"external.weather_lookup", "/mcp/external.weather_lookup",
|
||||
"external.exchange_rate", "/mcp/external.exchange_rate",
|
||||
"external.public_holiday_lookup", "/mcp/external.public_holiday_lookup",
|
||||
"external.geocoding_lookup", "/mcp/external.geocoding_lookup",
|
||||
"external.country_info_lookup", "/mcp/external.country_info_lookup")));
|
||||
putSeed(new BundleDefinition(
|
||||
"business-tools", "Business Tool Server",
|
||||
"http://localhost:9090/tool-manifest", "http://localhost:9090",
|
||||
"business.", true, 10, sharedApiKey,
|
||||
Map.of(
|
||||
"business.customer_search", "/mcp/business.customer_search",
|
||||
"business.order_status", "/mcp/business.order_status",
|
||||
"business.ticket_create", "/mcp/business.ticket_create")));
|
||||
putSeed(new BundleDefinition(
|
||||
"was-cus", "DAP WAS CUS Tool Server",
|
||||
"http://localhost:8084/tool-manifest", "http://localhost:8084",
|
||||
"", true, 10, null,
|
||||
cusToolEndpoints()));
|
||||
}
|
||||
|
||||
public List<BundleView> list() {
|
||||
return bundles.values().stream()
|
||||
.map(BundleState::view)
|
||||
.sorted(Comparator.comparing(view -> view.definition().bundleId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public BundleView create(BundleRequest request) {
|
||||
BundleDefinition definition = validated(request, null);
|
||||
if (bundles.containsKey(definition.bundleId())) {
|
||||
throw conflict("Bundle ID already exists: " + definition.bundleId());
|
||||
}
|
||||
ensureManifestUrlUnique(definition.manifestUrl(), null);
|
||||
BundleState state = new BundleState(definition);
|
||||
bundles.put(definition.bundleId(), state);
|
||||
registryRevision.incrementAndGet();
|
||||
return state.view();
|
||||
}
|
||||
|
||||
public BundleView update(String bundleId, BundleRequest request) {
|
||||
BundleState current = required(bundleId);
|
||||
BundleDefinition definition = validated(request, current.definition().apiKey());
|
||||
if (!bundleId.equals(definition.bundleId())) {
|
||||
throw badRequest("Bundle ID cannot be changed");
|
||||
}
|
||||
ensureManifestUrlUnique(definition.manifestUrl(), bundleId);
|
||||
current.update(definition);
|
||||
registryRevision.incrementAndGet();
|
||||
return current.view();
|
||||
}
|
||||
|
||||
public Map<String, Object> portalRegistry(String routeKey) {
|
||||
List<Map<String, Object>> services = bundles.values().stream()
|
||||
.filter(state -> state.definition().enabled())
|
||||
.filter(state -> belongsToRoute(state.definition(), routeKey))
|
||||
.map(state -> service(state.definition()))
|
||||
.sorted(Comparator.comparing(item -> String.valueOf(item.get("serviceKey"))))
|
||||
.toList();
|
||||
return Map.of(
|
||||
"routeKey", routeKey,
|
||||
"registryRevision", registryRevision.get(),
|
||||
"toolServices", services);
|
||||
}
|
||||
|
||||
public Map<String, Object> portalRegistry() {
|
||||
List<Map<String, Object>> routes = bundles.values().stream()
|
||||
.filter(state -> state.definition().enabled())
|
||||
.map(state -> routeKey(state.definition()))
|
||||
.distinct()
|
||||
.sorted()
|
||||
.map(routeKey -> Map.of(
|
||||
"routeKey", routeKey,
|
||||
"toolServices", bundles.values().stream()
|
||||
.filter(state -> state.definition().enabled())
|
||||
.filter(state -> belongsToRoute(state.definition(), routeKey))
|
||||
.map(state -> service(state.definition()))
|
||||
.sorted(Comparator.comparing(item -> String.valueOf(item.get("serviceKey"))))
|
||||
.toList()))
|
||||
.toList();
|
||||
return Map.of(
|
||||
"registryRevision", registryRevision.get(),
|
||||
"routes", routes);
|
||||
}
|
||||
|
||||
private boolean belongsToRoute(BundleDefinition definition, String routeKey) {
|
||||
String normalizedRoute = routeKey == null ? "" : routeKey.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return routeKey(definition).equalsIgnoreCase(normalizedRoute);
|
||||
}
|
||||
|
||||
private String routeKey(BundleDefinition definition) {
|
||||
String prefix = definition.namePrefix();
|
||||
if (prefix == null || prefix.isBlank()) {
|
||||
return definition.bundleId().startsWith("was-")
|
||||
? definition.bundleId().substring("was-".length())
|
||||
: definition.bundleId();
|
||||
}
|
||||
int dot = prefix.indexOf('.');
|
||||
int underscore = prefix.indexOf('_');
|
||||
int end = dot >= 0 && underscore >= 0 ? Math.min(dot, underscore) : Math.max(dot, underscore);
|
||||
return end > 0 ? prefix.substring(0, end) : prefix.replaceAll("[._]+$", "");
|
||||
}
|
||||
|
||||
public Map<String, Object> screenRegistry(String routeKey, String mcpEndpointUrl) {
|
||||
Map<String, Object> route = new LinkedHashMap<>();
|
||||
route.put("routeKey", routeKey);
|
||||
route.put("displayName", "Portal MCP Route");
|
||||
route.put("routePath", "/mcp/" + routeKey);
|
||||
route.put("mcpEndpointUrl", mcpEndpointUrl);
|
||||
route.put("status", "ACTIVE");
|
||||
|
||||
List<Map<String, Object>> services = list().stream().map(view -> {
|
||||
BundlePublicDefinition item = view.definition();
|
||||
Map<String, Object> service = new LinkedHashMap<>();
|
||||
service.put("serviceKey", item.bundleId());
|
||||
service.put("displayName", item.toolServerName());
|
||||
service.put("manifestUrl", item.manifestUrl());
|
||||
service.put("baseEndpoint", item.baseUrl());
|
||||
service.put("namePrefix", item.namePrefix());
|
||||
service.put("enabled", item.enabled());
|
||||
service.put("manifestPollIntervalSeconds", item.manifestPollIntervalSeconds());
|
||||
service.put("status", item.enabled() ? "ACTIVE" : "INACTIVE");
|
||||
return service;
|
||||
}).toList();
|
||||
return Map.of("mcpRoutes", List.of(route), "toolServices", services,
|
||||
"mappings", services.stream().map(service -> Map.of(
|
||||
"routeKey", routeKey,
|
||||
"serviceKey", service.get("serviceKey"),
|
||||
"status", service.get("status"),
|
||||
"source", "portal-bundle-registry",
|
||||
"registryRevision", registryRevision.get())).toList());
|
||||
}
|
||||
|
||||
public long bumpRevision() {
|
||||
return registryRevision.incrementAndGet();
|
||||
}
|
||||
|
||||
private Map<String, Object> service(BundleDefinition definition) {
|
||||
URI manifest = URI.create(definition.manifestUrl());
|
||||
String manifestPath = manifest.getRawPath();
|
||||
Map<String, Object> service = new LinkedHashMap<>();
|
||||
service.put("serviceKey", definition.bundleId());
|
||||
service.put("displayName", definition.toolServerName());
|
||||
service.put("serviceDomain", definition.baseUrl());
|
||||
service.put("manifestPath", manifestPath == null || manifestPath.isBlank() ? "/tool-manifest" : manifestPath);
|
||||
service.put("executeBasePath", "");
|
||||
service.put("namePrefix", definition.namePrefix());
|
||||
service.put("toolEndpoints", definition.toolEndpoints());
|
||||
service.put("status", definition.enabled() ? "ACTIVE" : "INACTIVE");
|
||||
return service;
|
||||
}
|
||||
|
||||
private BundleDefinition validated(BundleRequest request, String existingApiKey) {
|
||||
if (request == null) {
|
||||
throw badRequest("Request body is required");
|
||||
}
|
||||
String bundleId = requiredText(request.bundleId(), "bundleId");
|
||||
if (!BUNDLE_ID.matcher(bundleId).matches()) {
|
||||
throw badRequest("Bundle ID is invalid");
|
||||
}
|
||||
String name = requiredText(request.toolServerName(), "toolServerName");
|
||||
String manifestUrl = validUrl(request.manifestUrl(), "manifestUrl");
|
||||
String baseUrl = validUrl(request.baseUrl(), "baseUrl").replaceAll("/+$", "");
|
||||
String prefix = request.namePrefix() == null ? "" : request.namePrefix().trim();
|
||||
if (!prefix.isBlank() && !prefix.endsWith(".") && !prefix.endsWith("_")) {
|
||||
throw badRequest("Tool name prefix must end with '.' or '_'");
|
||||
}
|
||||
long interval = request.manifestPollIntervalSeconds();
|
||||
if (interval < 5 || interval > 86_400) {
|
||||
throw badRequest("Manifest poll interval must be between 5 and 86400 seconds");
|
||||
}
|
||||
Map<String, String> endpoints = request.toolEndpoints() == null
|
||||
? Map.of() : Map.copyOf(request.toolEndpoints());
|
||||
endpoints.forEach((toolName, path) -> {
|
||||
if (!TOOL_NAME.matcher(toolName).matches()
|
||||
|| (!prefix.isBlank() && !toolName.startsWith(prefix))) {
|
||||
throw badRequest("Tool endpoint name must start with " + prefix + ": " + toolName);
|
||||
}
|
||||
if (path == null || !path.startsWith("/") || path.startsWith("//")) {
|
||||
throw badRequest("Tool endpoint path must start with a single '/': " + toolName);
|
||||
}
|
||||
});
|
||||
String apiKey = request.apiKey() == null || request.apiKey().isBlank() ? existingApiKey : request.apiKey();
|
||||
return new BundleDefinition(bundleId, name, manifestUrl, baseUrl, prefix,
|
||||
request.enabled(), interval, apiKey, endpoints);
|
||||
}
|
||||
|
||||
private Map<String, String> cusToolEndpoints() {
|
||||
List<String> names = List.of(
|
||||
"cmm_comcode_lookup", "cmm_customer_tool", "cmm_meta_table", "cmm_template_url",
|
||||
"ins_insurance_processor", "oth_onnba3011_call",
|
||||
"smp_exchange_inquiry", "smp_quote_daily", "smp_team_list",
|
||||
"smp_weather_inquiry", "sol_request_detail", "sol_request_list");
|
||||
Map<String, String> endpoints = new LinkedHashMap<>();
|
||||
names.forEach(name -> endpoints.put(name, "/mcp/" + name));
|
||||
return Map.copyOf(endpoints);
|
||||
}
|
||||
|
||||
private void ensureManifestUrlUnique(String manifestUrl, String excludedBundleId) {
|
||||
boolean duplicate = bundles.values().stream().anyMatch(state ->
|
||||
!state.definition().bundleId().equals(excludedBundleId)
|
||||
&& state.definition().manifestUrl().equalsIgnoreCase(manifestUrl));
|
||||
if (duplicate) {
|
||||
throw conflict("Manifest URL already exists: " + manifestUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private BundleState required(String bundleId) {
|
||||
BundleState state = bundles.get(bundleId);
|
||||
if (state == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Bundle not found: " + bundleId);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private void putSeed(BundleDefinition definition) {
|
||||
bundles.put(definition.bundleId(), new BundleState(definition));
|
||||
}
|
||||
|
||||
private String requiredText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw badRequest(field + " is required");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String validUrl(String value, String field) {
|
||||
String text = requiredText(value, field);
|
||||
try {
|
||||
URI uri = URI.create(text);
|
||||
if (!uri.isAbsolute() || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|
||||
|| uri.getHost() == null) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
return uri.toString();
|
||||
} catch (RuntimeException error) {
|
||||
throw badRequest(field + " must be an absolute HTTP(S) URL");
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseStatusException badRequest(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
private ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
public record BundleRequest(
|
||||
String bundleId,
|
||||
String toolServerName,
|
||||
String manifestUrl,
|
||||
String baseUrl,
|
||||
String namePrefix,
|
||||
boolean enabled,
|
||||
long manifestPollIntervalSeconds,
|
||||
String apiKey,
|
||||
Map<String, String> toolEndpoints) {
|
||||
}
|
||||
|
||||
public record BundleDefinition(
|
||||
String bundleId,
|
||||
String toolServerName,
|
||||
String manifestUrl,
|
||||
String baseUrl,
|
||||
String namePrefix,
|
||||
boolean enabled,
|
||||
long manifestPollIntervalSeconds,
|
||||
String apiKey,
|
||||
Map<String, String> toolEndpoints) {
|
||||
}
|
||||
|
||||
public record BundlePublicDefinition(
|
||||
String bundleId,
|
||||
String toolServerName,
|
||||
String manifestUrl,
|
||||
String baseUrl,
|
||||
String namePrefix,
|
||||
boolean enabled,
|
||||
long manifestPollIntervalSeconds,
|
||||
boolean apiKeyConfigured,
|
||||
Map<String, String> toolEndpoints) {
|
||||
}
|
||||
|
||||
public record BundleView(
|
||||
BundlePublicDefinition definition,
|
||||
String manifestRevision,
|
||||
String lastSynchronizedAt,
|
||||
String lastError,
|
||||
List<Map<String, Object>> tools) {
|
||||
}
|
||||
|
||||
private static final class BundleState {
|
||||
private volatile BundleDefinition definition;
|
||||
|
||||
private BundleState(BundleDefinition definition) {
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
private synchronized void update(BundleDefinition definition) {
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
private BundleDefinition definition() { return definition; }
|
||||
|
||||
private BundleView view() {
|
||||
BundleDefinition item = definition;
|
||||
BundlePublicDefinition publicDefinition = new BundlePublicDefinition(
|
||||
item.bundleId(), item.toolServerName(), item.manifestUrl(), item.baseUrl(),
|
||||
item.namePrefix(), item.enabled(), item.manifestPollIntervalSeconds(),
|
||||
item.apiKey() != null && !item.apiKey().isBlank(), item.toolEndpoints());
|
||||
return new BundleView(publicDefinition, null, null, null, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
server:
|
||||
port: ${AGENT_TEST_PORT:7070}
|
||||
|
||||
agent-test:
|
||||
mcp:
|
||||
endpoint-url: ${MCP_ENDPOINT_URL:http://localhost:8080/mcp}
|
||||
protocol-version: ${MCP_PROTOCOL_VERSION:2025-11-25}
|
||||
tool-server:
|
||||
manifest-url: ${TOOL_MANIFEST_URL:http://localhost:9092/tool-manifest}
|
||||
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
|
||||
portal:
|
||||
registry-revision: ${PORTAL_REGISTRY_REVISION:1}
|
||||
route-key: ${PORTAL_ROUTE_KEY:cus}
|
||||
tool-service-domain: ${TOOL_SERVICE_DOMAIN:http://localhost:9092}
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.example.agenttest: INFO
|
||||
@@ -1,625 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AX HUB Portal PoC</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--surface: #ffffff;
|
||||
--line: #d7dde8;
|
||||
--text: #172033;
|
||||
--muted: #667085;
|
||||
--primary: #1d5fd1;
|
||||
--primary-dark: #164aa5;
|
||||
--soft: #eef3fb;
|
||||
--good: #087443;
|
||||
--bad: #b42318;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #162033;
|
||||
color: #fff;
|
||||
padding: 18px 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1320px, calc(100% - 32px));
|
||||
margin: 18px auto 28px;
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 5px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e7f6ee;
|
||||
color: var(--good);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 92px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
min-height: 104px;
|
||||
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
min-height: 38px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #344054;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #202939;
|
||||
}
|
||||
|
||||
button.soft {
|
||||
background: var(--soft);
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
button.soft:hover {
|
||||
background: #dfe8f6;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
min-height: 300px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
background: #101828;
|
||||
color: #e5e7eb;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.answer {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
min-height: 80px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.manual-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
header,
|
||||
main,
|
||||
.two-col,
|
||||
.manual-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>AX HUB Portal PoC</h1>
|
||||
<div class="header-meta" style="text-align:left">Hardcoded Registry + Agent Backend + MCP Tool Call</div>
|
||||
</div>
|
||||
<div class="header-meta" id="config">Loading configuration...</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="stack">
|
||||
<section>
|
||||
<h2>Portal Registry</h2>
|
||||
<p class="header-meta" style="text-align:left;color:var(--muted)">필요할 때 MCP가 Registry를 다시 확인하도록 revision을 갱신합니다.</p>
|
||||
<input id="routeKey" value="cus" type="hidden">
|
||||
<div class="actions">
|
||||
<button onclick="bumpRevision()">Portal Revision 올리기</button>
|
||||
<button onclick="openNewBundle()">+ Tool Server 추가</button>
|
||||
</div>
|
||||
<details id="bundleEditor" style="margin-top:14px">
|
||||
<summary><strong>Tool Server 설정</strong></summary>
|
||||
<div style="margin-top:12px">
|
||||
<label for="bundleId">Bundle ID</label>
|
||||
<input id="bundleId" placeholder="business-tools">
|
||||
<label for="toolServerName">Tool Server 이름</label>
|
||||
<input id="toolServerName" placeholder="Business Tool Server">
|
||||
<label for="manifestUrl">Manifest URL</label>
|
||||
<input id="manifestUrl" placeholder="http://localhost:9090/tool-manifest">
|
||||
<label for="baseUrl">실행 Base URL</label>
|
||||
<input id="baseUrl" placeholder="http://localhost:9090">
|
||||
<label for="namePrefix">Tool name prefix</label>
|
||||
<input id="namePrefix" placeholder="business.">
|
||||
<label><input id="bundleEnabled" type="checkbox" checked style="width:auto"> 활성화</label>
|
||||
<details style="margin-top:12px">
|
||||
<summary>고급 설정</summary>
|
||||
<label for="pollInterval">Manifest 조회 주기(초)</label>
|
||||
<input id="pollInterval" type="number" min="5" value="60">
|
||||
<label for="apiKey">Tool Server API Key (입력 전용)</label>
|
||||
<input id="apiKey" type="password" autocomplete="new-password" placeholder="기존 Key를 유지하려면 비워두세요">
|
||||
<label for="toolEndpoints">Tool 실행 경로 매핑(JSON)</label>
|
||||
<textarea id="toolEndpoints">{}</textarea>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button onclick="saveBundle()">저장</button>
|
||||
<button class="soft" onclick="closeBundleEditor()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>MCP 연결 테스트</strong></summary>
|
||||
<label for="mcpToolServerFilter">확인할 Tool Server</label>
|
||||
<select id="mcpToolServerFilter" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
|
||||
<option value="external.">External Tool Server</option>
|
||||
<option value="business.">Business Tool Server</option>
|
||||
<option value="cus">DAP WAS CUS Tool Server</option>
|
||||
</select>
|
||||
<div class="actions">
|
||||
<button onclick="callApi('POST', mcpTestAction('initialize'))">Initialize</button>
|
||||
<button class="secondary" onclick="callApi('POST', mcpTestAction('initialized'))">Initialized</button>
|
||||
<button onclick="loadMcpTools()">Tools/List</button>
|
||||
</div>
|
||||
<div id="mcpToolList" class="stack" style="margin-top:12px"></div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>Agent 테스트</strong></summary>
|
||||
<label for="message">사용자 요청</label>
|
||||
<textarea id="message" class="chat-box">서울 날씨 알려줘</textarea>
|
||||
<div class="actions">
|
||||
<button onclick="sendChat()">Agent 실행</button>
|
||||
<button class="soft" onclick="setMessage('서울 날씨 알려줘')">서울 날씨</button>
|
||||
<button class="soft" onclick="setMessage('부산 날씨 알려줘')">부산 날씨</button>
|
||||
<button class="soft" onclick="setMessage('달러 환율 알려줘')">달러 환율</button>
|
||||
<button class="soft" onclick="setMessage('대한민국 공휴일 조회해줘')">공휴일 조회</button>
|
||||
<button class="soft" onclick="setMessage('서울 좌표 조회해줘')">서울 좌표</button>
|
||||
<button class="soft" onclick="setMessage('대한민국 국가 정보 조회해줘')">국가 정보</button>
|
||||
<button class="soft" onclick="setMessage('고객 조회해줘')">고객 조회</button>
|
||||
<button class="soft" onclick="setMessage('주문 상태 조회해줘')">주문 상태</button>
|
||||
<button class="soft" onclick="setMessage('고객 문의 티켓 생성해줘')">티켓 생성</button>
|
||||
<button class="soft" onclick="setMessage('메타 공통코드 조회해줘')">메타 공통코드</button>
|
||||
<button class="soft" onclick="setMessage('메타 테이블 조회해줘')">메타 테이블</button>
|
||||
<button class="soft" onclick="setMessage('템플릿 다운로드 URL 알려줘')">템플릿 URL</button>
|
||||
<button class="soft" onclick="setMessage('SOL 의뢰서 목록 조회해줘')">SOL 목록</button>
|
||||
<button class="soft" onclick="setMessage('SOL 의뢰서 상세 조회해줘')">SOL 상세</button>
|
||||
<button class="soft" onclick="setMessage('보험금 청구 처리해줘')">보험금 청구</button>
|
||||
<button class="soft" onclick="setMessage('가입설계 한도 조회해줘')">가입설계 한도</button>
|
||||
<button class="soft" onclick="setMessage('CUS 달러 환율 조회해줘')">CUS 환율</button>
|
||||
<button class="soft" onclick="setMessage('CUS 서울 날씨 조회해줘')">CUS 날씨</button>
|
||||
<button class="soft" onclick="setMessage('오늘의 명언 알려줘')">오늘의 명언</button>
|
||||
<button class="soft" onclick="setMessage('TOOL 파트 구성원 조회해줘')">TOOL 구성원</button>
|
||||
</div>
|
||||
<div class="answer" id="answer">Agent 응답 대기 중</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>Tool 직접 호출</strong></summary>
|
||||
<label for="toolPreset">호출할 Tool</label>
|
||||
<select id="toolPreset" onchange="setToolPreset(this.value)" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
|
||||
<option value="external.weather_lookup">날씨 조회</option>
|
||||
<option value="external.exchange_rate">환율 조회</option>
|
||||
<option value="external.public_holiday_lookup">공휴일 조회</option>
|
||||
<option value="external.geocoding_lookup">도시 좌표 조회</option>
|
||||
<option value="external.country_info_lookup">국가 정보 조회</option>
|
||||
<option value="business.customer_search">고객 검색</option>
|
||||
<option value="business.order_status">주문 상태 조회</option>
|
||||
<option value="business.ticket_create">지원 티켓 생성(승인 필요)</option>
|
||||
<option value="cmm_comcode_lookup">메타 공통코드 조회</option>
|
||||
<option value="cmm_customer_tool">고객 통합 안내이력 조회</option>
|
||||
<option value="cmm_meta_table">메타 테이블 조회</option>
|
||||
<option value="cmm_template_url">템플릿 URL 조회</option>
|
||||
<option value="sol_request_list">SOL 의뢰서 목록</option>
|
||||
<option value="sol_request_detail">SOL 의뢰서 상세</option>
|
||||
<option value="ins_insurance_processor">보험금 청구 처리</option>
|
||||
<option value="oth_onnba3011_call">가입설계 한도 조회</option>
|
||||
<option value="smp_exchange_inquiry">CUS 환율 조회</option>
|
||||
<option value="smp_weather_inquiry">CUS 날씨 조회</option>
|
||||
<option value="smp_quote_daily">오늘의 명언</option>
|
||||
<option value="smp_team_list">TOOL 파트 구성원</option>
|
||||
</select>
|
||||
<div class="manual-grid">
|
||||
<div>
|
||||
<label for="toolName">Tool Name</label>
|
||||
<input id="toolName" value="external.weather_lookup">
|
||||
</div>
|
||||
<div>
|
||||
<label for="arguments">Arguments JSON</label>
|
||||
<textarea id="arguments">{
|
||||
"city": "Seoul",
|
||||
"timezone": "Asia/Seoul"
|
||||
}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="callTool()">Tools/Call</button>
|
||||
<button class="soft" onclick="setWeather()">Weather Args</button>
|
||||
<button class="soft" onclick="setExchange()">Exchange Args</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary><strong>상세 응답 보기</strong></summary>
|
||||
<pre id="output">Waiting...</pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const output = document.getElementById("output");
|
||||
const config = document.getElementById("config");
|
||||
const answer = document.getElementById("answer");
|
||||
|
||||
async function callApi(method, url, body) {
|
||||
output.textContent = "Requesting...";
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
output.textContent = JSON.stringify(data, null, 2);
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || data.message || data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
output.textContent = JSON.stringify({error: error.message}, null, 2);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const data = await callApi("GET", "/api/config");
|
||||
document.getElementById("routeKey").value = data.defaultRouteKey || "cus";
|
||||
config.innerHTML = `MCP ${data.mcpEndpointUrl}<br>Route MCP ${data.defaultRoutedMcpEndpointUrl}<br>Manifest ${data.toolManifestUrl}`;
|
||||
}
|
||||
|
||||
async function loadRegistry() {
|
||||
const bundles = await callApi("GET", "/api/portal/bundles");
|
||||
window.portalBundles = bundles;
|
||||
}
|
||||
|
||||
function openNewBundle() {
|
||||
clearBundleForm();
|
||||
document.getElementById("bundleEditor").open = true;
|
||||
}
|
||||
|
||||
function closeBundleEditor() {
|
||||
document.getElementById("bundleEditor").open = false;
|
||||
}
|
||||
|
||||
function clearBundleForm() {
|
||||
["bundleId", "toolServerName", "manifestUrl", "baseUrl", "namePrefix", "apiKey"]
|
||||
.forEach(id => document.getElementById(id).value = "");
|
||||
document.getElementById("bundleId").readOnly = false;
|
||||
document.getElementById("pollInterval").value = 60;
|
||||
document.getElementById("toolEndpoints").value = "{}";
|
||||
document.getElementById("bundleEnabled").checked = true;
|
||||
}
|
||||
|
||||
async function saveBundle() {
|
||||
const idInput = document.getElementById("bundleId");
|
||||
const payload = {
|
||||
bundleId: idInput.value.trim(),
|
||||
toolServerName: document.getElementById("toolServerName").value.trim(),
|
||||
manifestUrl: document.getElementById("manifestUrl").value.trim(),
|
||||
baseUrl: document.getElementById("baseUrl").value.trim(),
|
||||
namePrefix: document.getElementById("namePrefix").value.trim(),
|
||||
enabled: document.getElementById("bundleEnabled").checked,
|
||||
manifestPollIntervalSeconds: Number(document.getElementById("pollInterval").value),
|
||||
apiKey: document.getElementById("apiKey").value,
|
||||
toolEndpoints: JSON.parse(document.getElementById("toolEndpoints").value || "{}")
|
||||
};
|
||||
const url = idInput.readOnly
|
||||
? `/api/portal/bundles/${encodeURIComponent(payload.bundleId)}`
|
||||
: "/api/portal/bundles";
|
||||
await callApi(idInput.readOnly ? "PUT" : "POST", url, payload);
|
||||
await loadRegistry();
|
||||
closeBundleEditor();
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[char]);
|
||||
}
|
||||
|
||||
function escapeJs(value) {
|
||||
return String(value).replace(/[\\']/g, "\\$&");
|
||||
}
|
||||
|
||||
async function bumpRevision() {
|
||||
const data = await callApi("POST", `/api/portal/registry/${selectedRoute()}/revision`);
|
||||
await loadRegistry();
|
||||
output.textContent = JSON.stringify({
|
||||
message: "Portal registry revision bumped",
|
||||
routeKey: data.routeKey,
|
||||
registryRevision: data.registryRevision
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function renderGroup(title, rows) {
|
||||
return `<div><h3>${title}</h3>${rows.map(renderItem).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderItem(row) {
|
||||
const title = row.displayName || row.serviceKey || row.routeKey;
|
||||
const entries = Object.entries(row)
|
||||
.map(([key, value]) => `<div>${key}</div><div>${value}</div>`)
|
||||
.join("");
|
||||
return `<div class="item"><div class="item-title">${title} <span class="badge">${row.status || row.source}</span></div><div class="kv">${entries}</div></div>`;
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const message = document.getElementById("message").value.trim();
|
||||
answer.textContent = "Agent is selecting a tool...";
|
||||
const data = await callApi("POST", "/api/agent/chat", {message});
|
||||
answer.innerHTML = `<strong>${data.answer}</strong><br><br>Agent route 결정: ${data.routeKey}<br>판단: ${data.routeDecision}<br>MCP: ${data.mcpEndpointUrl}<br>Tool: ${data.selectedTool}<br>인자: ${JSON.stringify(data.arguments)}`;
|
||||
}
|
||||
|
||||
async function callTool() {
|
||||
const name = document.getElementById("toolName").value.trim();
|
||||
const args = JSON.parse(document.getElementById("arguments").value);
|
||||
const routeKey = name.startsWith("business.") ? "business"
|
||||
: /^(cmm|ins|oth|smp|sol)_/.test(name) ? "cus" : "external";
|
||||
await callApi("POST", `/api/mcp/${routeKey}/tools/call`, {name, arguments: args});
|
||||
}
|
||||
|
||||
function setToolPreset(name) {
|
||||
const presets = {
|
||||
"external.weather_lookup": {city: "Seoul", timezone: "Asia/Seoul"},
|
||||
"external.exchange_rate": {from: "USD", to: "KRW"},
|
||||
"external.public_holiday_lookup": {countryCode: "KR", year: new Date().getFullYear()},
|
||||
"external.geocoding_lookup": {city: "Seoul", language: "ko"},
|
||||
"external.country_info_lookup": {countryCode: "KR"},
|
||||
"business.customer_search": {keyword: "C-1001"},
|
||||
"business.order_status": {orderId: "O-9001"},
|
||||
"business.ticket_create": {
|
||||
title: "Portal test ticket",
|
||||
priority: "normal",
|
||||
description: "Created from the Portal Tool test screen"
|
||||
},
|
||||
"cmm_comcode_lookup": {groupCode: "GRP_COMM_CD", useYn: "Y"},
|
||||
"cmm_customer_tool": {csNo: "000000000001"},
|
||||
"cmm_meta_table": {tableName: "TB_CUST_BAS", owner: "DAPADM"},
|
||||
"cmm_template_url": {templateId: "TPL_001"},
|
||||
"sol_request_list": {status: "진행중", period: "1개월", target: "나의 업무"},
|
||||
"sol_request_detail": {srId: "SR-001"},
|
||||
"ins_insurance_processor": {claimNumber: "CLM20230001", claimAmount: 1500000, claimDate: "2026-08-12"},
|
||||
"oth_onnba3011_call": {dalScCd: "1", cstSucoRltyCd: "01", csNo: "000000000001"},
|
||||
"smp_exchange_inquiry": {currencyCode: "USD"},
|
||||
"smp_weather_inquiry": {city: "서울"},
|
||||
"smp_quote_daily": {category: "속담"},
|
||||
"smp_team_list": {teamName: "TOOL"}
|
||||
};
|
||||
document.getElementById("toolName").value = name;
|
||||
document.getElementById("arguments").value = JSON.stringify(presets[name] || {}, null, 2);
|
||||
}
|
||||
|
||||
function selectedRoute() {
|
||||
const route = document.getElementById("routeKey").value.trim();
|
||||
return route || "cus";
|
||||
}
|
||||
|
||||
function mcpAction(action) {
|
||||
return `/api/mcp/${selectedRoute()}/${action}`;
|
||||
}
|
||||
|
||||
async function loadMcpTools() {
|
||||
const data = await callApi("POST", mcpTestAction("tools/list"));
|
||||
const prefix = document.getElementById("mcpToolServerFilter").value;
|
||||
const tools = (((data || {}).body || {}).result || {}).tools || [];
|
||||
const filtered = prefix === "all" || prefix === "cus"
|
||||
? tools : tools.filter(tool => tool.name.startsWith(prefix));
|
||||
const target = document.getElementById("mcpToolList");
|
||||
target.innerHTML = filtered.length ? filtered.map(tool => `
|
||||
<div class="item">
|
||||
<div class="item-title">${escapeHtml(tool.title || tool.name)}</div>
|
||||
<div class="kv">
|
||||
<div>name</div><div>${escapeHtml(tool.name)}</div>
|
||||
<div>설명</div><div>${escapeHtml(tool.description || "-")}</div>
|
||||
<div>유형</div><div>${tool.annotations && tool.annotations.readOnlyHint ? "READ" : "WRITE"}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="soft" onclick="prepareToolCall('${escapeJs(tool.name)}')">이 Tool 호출</button>
|
||||
</div>
|
||||
</div>`).join("") : `<div class="header-meta" style="text-align:left;color:var(--muted)">선택한 서버의 Tool이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
function mcpTestAction(action) {
|
||||
const filter = document.getElementById("mcpToolServerFilter").value;
|
||||
const routeKey = filter === "business." ? "business"
|
||||
: filter === "cus" ? "cus" : "external";
|
||||
return `/api/mcp/${routeKey}/${action}`;
|
||||
}
|
||||
|
||||
function prepareToolCall(name) {
|
||||
setToolPreset(name);
|
||||
document.getElementById("toolPreset").value = name;
|
||||
document.getElementById("toolPreset").closest("details").open = true;
|
||||
document.getElementById("toolPreset").scrollIntoView({behavior: "smooth", block: "center"});
|
||||
}
|
||||
|
||||
function setMessage(value) {
|
||||
document.getElementById("message").value = value;
|
||||
}
|
||||
|
||||
function setWeather() {
|
||||
document.getElementById("toolName").value = "external.weather_lookup";
|
||||
document.getElementById("arguments").value = JSON.stringify({
|
||||
city: "Seoul",
|
||||
timezone: "Asia/Seoul"
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function setExchange() {
|
||||
document.getElementById("toolName").value = "external.exchange_rate";
|
||||
document.getElementById("arguments").value = JSON.stringify({
|
||||
from: "USD",
|
||||
to: "KRW"
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
loadConfig().then(loadRegistry).catch(error => {
|
||||
config.textContent = "Configuration load failed";
|
||||
output.textContent = JSON.stringify({error: error.message}, null, 2);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,123 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class McpProxyControllerTest {
|
||||
|
||||
@Test
|
||||
void treatsDirectExternalToolPayloadAsSuccessfulResult() throws Exception {
|
||||
var payload = new com.fasterxml.jackson.databind.ObjectMapper().readTree(
|
||||
"{\"year\":2026,\"holidays\":[{\"date\":\"2026-01-01\",\"localName\":\"New Year\"}]}" );
|
||||
|
||||
assertThat(McpProxyController.answer("external.public_holiday_lookup", payload))
|
||||
.contains("2026", "1", "2026-01-01", "New Year")
|
||||
.doesNotContain("실패");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsDirectOthToolPayloadAsSuccessfulResult() throws Exception {
|
||||
var payload = new com.fasterxml.jackson.databind.ObjectMapper().readTree(
|
||||
"{\"codeList\":[{\"code\":\"CD001\",\"codeName\":\"진행중\"}]}");
|
||||
|
||||
assertThat(McpProxyController.answer("cmm_comcode_lookup", payload))
|
||||
.contains("cmm_comcode_lookup 실행 결과")
|
||||
.contains("CD001")
|
||||
.doesNotContain("실패");
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void portalRegistryReturnsOkForExternalRoute() throws Exception {
|
||||
mockMvc.perform(get("/api/portal/registry/external"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.routeKey").value("external"))
|
||||
.andExpect(jsonPath("$.toolServices.length()").value(1))
|
||||
.andExpect(jsonPath("$.toolServices[0].serviceKey").value("external-tools"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bumpsPortalRegistryRevision() throws Exception {
|
||||
mockMvc.perform(post("/api/portal/registry/external/revision"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.routeKey").value("external"))
|
||||
.andExpect(jsonPath("$.registryRevision").isNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatePortalRegistryReturnsEveryRouteAndToolService() throws Exception {
|
||||
mockMvc.perform(get("/api/portal/registry"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.registryRevision").isNumber())
|
||||
.andExpect(jsonPath("$.routes[?(@.routeKey=='external')].toolServices[0].serviceKey")
|
||||
.value("external-tools"))
|
||||
.andExpect(jsonPath("$.routes[?(@.routeKey=='business')].toolServices[0].serviceKey")
|
||||
.value("business-tools"))
|
||||
.andExpect(jsonPath("$.routes[?(@.routeKey=='cus')].toolServices[0].serviceKey")
|
||||
.value("was-cus"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesBusinessBundleAndEndpointMappingsWithoutApiKey() throws Exception {
|
||||
mockMvc.perform(get("/api/portal/bundles"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].definition.apiKey").doesNotExist())
|
||||
.andExpect(jsonPath("$[0].definition.apiKeyConfigured").isBoolean())
|
||||
.andExpect(jsonPath("$[0].definition.bundleId").value("business-tools"))
|
||||
.andExpect(jsonPath("$[0].definition.toolEndpoints['business.customer_search']")
|
||||
.value("/mcp/business.customer_search"));
|
||||
|
||||
mockMvc.perform(get("/api/portal/registry/business"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.toolServices[0].serviceKey").value("business-tools"))
|
||||
.andExpect(jsonPath("$.toolServices[0].toolEndpoints['business.ticket_create']")
|
||||
.value("/mcp/business.ticket_create"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsPublicHolidayToolForKoreanHolidayRequest() {
|
||||
McpProxyController.PlannedTool plan = McpProxyController.planRequest("지금 현재 공휴일 조회해줘");
|
||||
|
||||
assertThat(plan.name()).isEqualTo("external.public_holiday_lookup");
|
||||
assertThat(plan.routeKey()).isEqualTo("external");
|
||||
assertThat(plan.arguments()).containsEntry("countryCode", "KR").containsKey("year");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsEveryManifestToolFromNaturalLanguageExamples() {
|
||||
assertThat(McpProxyController.planRequest("서울 날씨 조회").name()).isEqualTo("external.weather_lookup");
|
||||
assertThat(McpProxyController.planRequest("달러 환율 조회").name()).isEqualTo("external.exchange_rate");
|
||||
assertThat(McpProxyController.planRequest("서울 좌표 조회").name()).isEqualTo("external.geocoding_lookup");
|
||||
assertThat(McpProxyController.planRequest("한국 국가 정보 조회").name()).isEqualTo("external.country_info_lookup");
|
||||
assertThat(McpProxyController.planRequest("고객 검색").name()).isEqualTo("business.customer_search");
|
||||
assertThat(McpProxyController.planRequest("주문 상태 조회").name()).isEqualTo("business.order_status");
|
||||
assertThat(McpProxyController.planRequest("고객 지원 티켓 생성").name()).isEqualTo("business.ticket_create");
|
||||
assertThat(McpProxyController.planRequest("메타 공통코드 조회").name()).isEqualTo("cmm_comcode_lookup");
|
||||
assertThat(McpProxyController.planRequest("메타 테이블 조회").name()).isEqualTo("cmm_meta_table");
|
||||
assertThat(McpProxyController.planRequest("템플릿 다운로드 URL 알려줘").name()).isEqualTo("cmm_template_url");
|
||||
assertThat(McpProxyController.planRequest("SOL 의뢰서 목록 조회").name()).isEqualTo("sol_request_list");
|
||||
assertThat(McpProxyController.planRequest("SOL 의뢰서 상세 조회").name()).isEqualTo("sol_request_detail");
|
||||
assertThat(McpProxyController.planRequest("보험금 청구 처리").name()).isEqualTo("ins_insurance_processor");
|
||||
assertThat(McpProxyController.planRequest("가입설계 한도 조회").name()).isEqualTo("oth_onnba3011_call");
|
||||
assertThat(McpProxyController.planRequest("CUS 달러 환율 조회").name()).isEqualTo("smp_exchange_inquiry");
|
||||
assertThat(McpProxyController.planRequest("CUS 서울 날씨 조회").name()).isEqualTo("smp_weather_inquiry");
|
||||
assertThat(McpProxyController.planRequest("오늘의 명언 알려줘").name()).isEqualTo("smp_quote_daily");
|
||||
assertThat(McpProxyController.planRequest("TOOL 파트 구성원 조회").name()).isEqualTo("smp_team_list");
|
||||
assertThat(McpProxyController.planRequest("고객 검색").routeKey()).isEqualTo("business");
|
||||
assertThat(McpProxyController.planRequest("서울 날씨 조회").routeKey()).isEqualTo("external");
|
||||
assertThat(McpProxyController.planRequest("메타 테이블 조회").routeKey()).isEqualTo("cus");
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class PortalApiExceptionHandlerTest {
|
||||
|
||||
@Test
|
||||
void exposesSafeValidationReasonToPortalUi() {
|
||||
var response = new PortalApiExceptionHandler().handle(
|
||||
new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Manifest Bundle ID does not match configuration"));
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).containsAllEntriesOf(Map.of(
|
||||
"status", 400,
|
||||
"error", "400 BAD_REQUEST",
|
||||
"message", "Manifest Bundle ID does not match configuration"));
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package com.example.agenttest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.example.agenttest.PortalBundleService.BundleRequest;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class PortalBundleServiceTest {
|
||||
|
||||
@Test
|
||||
void exposesSeededWasCusBundleOnCusRoute() {
|
||||
PortalBundleService service = new PortalBundleService(properties(), RestClient.builder());
|
||||
|
||||
var bundle = service.list().stream()
|
||||
.filter(item -> item.definition().bundleId().equals("was-cus"))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
assertThat(bundle.definition().manifestUrl()).isEqualTo("http://localhost:8084/tool-manifest");
|
||||
assertThat(bundle.definition().namePrefix()).isEmpty();
|
||||
assertThat(bundle.definition().manifestPollIntervalSeconds()).isEqualTo(10);
|
||||
assertThat(bundle.definition().toolEndpoints())
|
||||
.containsEntry("smp_weather_inquiry", "/mcp/smp_weather_inquiry")
|
||||
.containsEntry("ins_insurance_processor", "/mcp/ins_insurance_processor")
|
||||
.containsEntry("cmm_customer_tool", "/mcp/cmm_customer_tool")
|
||||
.hasSize(12);
|
||||
assertThat(service.portalRegistry("cus").get("toolServices").toString()).contains("was-cus");
|
||||
assertThat(service.portalRegistry("external").get("toolServices").toString()).doesNotContain("was-cus");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesOnlyEndpointRegistryWithoutManifestSnapshot() {
|
||||
PortalBundleService service = new PortalBundleService(properties(), RestClient.builder());
|
||||
|
||||
var business = service.list().stream()
|
||||
.filter(item -> item.definition().bundleId().equals("business-tools"))
|
||||
.findFirst().orElseThrow();
|
||||
Map<String, Object> registry = service.portalRegistry("business");
|
||||
|
||||
assertThat(business.manifestRevision()).isNull();
|
||||
assertThat(business.lastSynchronizedAt()).isNull();
|
||||
assertThat(business.lastError()).isNull();
|
||||
assertThat(business.tools()).isEmpty();
|
||||
assertThat(registry.get("toolServices").toString())
|
||||
.contains("serviceDomain=http://localhost:9090")
|
||||
.contains("manifestPath=/tool-manifest")
|
||||
.contains("business.customer_search=/mcp/business.customer_search");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateBundleIdManifestUrlAndInvalidUrl() {
|
||||
PortalBundleService service = new PortalBundleService(properties(), RestClient.builder());
|
||||
BundleRequest duplicateId = request("business-tools", "http://localhost:9191/tool-manifest");
|
||||
BundleRequest duplicateUrl = request("another-tools", "http://localhost:9090/tool-manifest");
|
||||
BundleRequest invalidUrl = request("invalid-tools", "not-a-url");
|
||||
|
||||
assertThatThrownBy(() -> service.create(duplicateId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("409 CONFLICT");
|
||||
assertThatThrownBy(() -> service.create(duplicateUrl))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("409 CONFLICT");
|
||||
assertThatThrownBy(() -> service.create(invalidUrl))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("400 BAD_REQUEST");
|
||||
}
|
||||
|
||||
private BundleRequest request(String bundleId, String manifestUrl) {
|
||||
return new BundleRequest(bundleId, "Test", manifestUrl, "http://localhost:9191",
|
||||
"test.", true, 60, "", Map.of());
|
||||
}
|
||||
|
||||
private AgentTestProperties properties() {
|
||||
return new AgentTestProperties(
|
||||
new AgentTestProperties.Mcp("http://localhost:8080/mcp", "2025-11-25"),
|
||||
new AgentTestProperties.ToolServer("http://localhost:9092/tool-manifest", "secret-key"),
|
||||
new AgentTestProperties.Portal(1, "external", "http://localhost:9092"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.context;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 하나의 MCP HTTP 요청 전체에서 공유할 correlation·호출자·deadline 정보를 담는 불변 context입니다. {@link McpRequestContextFactory}가 만들고 HTTP transport, method handler, Tool client,
|
||||
* observability 계층이 사용하며 서버 대화 상태를 저장하지 않습니다. {@code guid}는 요청 하나를 끝까지 따라가는 상관 값이고 {@code requestId}는 개별 HTTP 요청 식별자입니다. {@code employeeNo}와
|
||||
* {@code virtualEmployeeNo}는 호출자가 암호화해 보낸
|
||||
* <b>불투명 값</b>입니다. MCP는 이를 복호화하거나 해석하지 않고 Tool Service로 그대로 전달하기만 하며, 로그에는 절대 남기지 않습니다.
|
||||
*/
|
||||
public record McpRequestContext(
|
||||
String routeKey,
|
||||
String requestId,
|
||||
String guid,
|
||||
String mcpSessionId,
|
||||
String employeeNo,
|
||||
String virtualEmployeeNo,
|
||||
String authorization,
|
||||
Instant deadline) {
|
||||
|
||||
/**
|
||||
* deadline이 없는 context를 허용하되 <b>이미 만료된 것으로</b> 취급합니다.
|
||||
*
|
||||
* <p>정상 경로에서는 {@link McpRequestContextFactory}가 항상 값을 채우므로 null이 올 수 없습니다. 그래도 null을 현재 시각으로 바꾸는
|
||||
* 이유는, 만약 잘못 만들어진 context가 흘러들어오면 {@link #remainingMillis()}가 0 이하가 되어 Tool 호출이 즉시 중단되기 때문입니다. 시간 제한 없이 무한정 호출되는 것보다 안전한 쪽으로 실패합니다.
|
||||
*/
|
||||
public McpRequestContext {
|
||||
deadline = deadline == null ? Instant.now() : deadline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 요청에 남은 시간을 밀리초로 알려 줍니다.
|
||||
*
|
||||
* <p>Tool 호출 직전마다 계산해, Tool 하나가 자기 timeout을 다 쓰더라도 요청 전체 예산을 넘기지 않도록 read timeout을 깎는 데 씁니다. 이미
|
||||
* 시간이 다 됐으면 0 이하가 되고, 그때는 Tool을 호출하지 않고 timeout으로 끝냅니다.
|
||||
*
|
||||
* <p>이 예산은 Agent Builder가 연결을 끊는 시각보다 <b>짧아야</b> 합니다. 같거나 길면 MCP가 응답을 만들어도 받을 상대가 이미 사라진 뒤입니다.
|
||||
*/
|
||||
public long remainingMillis() {
|
||||
return Duration.between(Instant.now(), deadline).toMillis();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.execute;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* MCP {@code tools/call} 요청에서 추출한 도구명과 arguments를 운반하는 불변 값 객체입니다. HTTP 요청을 직접 처리하지 않으며 tools/call handler가 만들고 Tool 실행 계층이 소비합니다. {@code arguments}는 원본 JSON
|
||||
* 구조를 보존해 이후 schema 검증과 Tool Service 호출에 사용합니다.
|
||||
*/
|
||||
public record ToolCall(String toolName, JsonNode arguments) {
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.execute;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.observability.TraceLogger;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolRegistryService;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolClientException;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.toolclient.ToolClient.ToolResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* MCP Tool 실행의 orchestration 서비스입니다. {@code tools/call}의 단일 Tool 실행 단계를 만들고 routing된 HTTP 요청을 실행하며, timeout·권한·실패를 JSON-RPC 내부 오류로 정규화합니다. 주요 의존성은 Registry,
|
||||
* argument validator, routing service, {@link ToolClient}와 Tool HTTP 호출·응답 경계를 기록하는 trace logger입니다.
|
||||
*/
|
||||
@Service
|
||||
public class ToolExecutionService {
|
||||
|
||||
private static final long STALE_REFRESH_COOLDOWN_NANOS = Duration.ofSeconds(5).toNanos();
|
||||
|
||||
private final ToolRegistryService registryService;
|
||||
private final ToolArgumentValidator argumentValidator;
|
||||
private final ToolRoutingService routingService;
|
||||
private final ToolClient toolClient;
|
||||
private final TraceLogger traceLogger;
|
||||
private final ConcurrentMap<String, Long> staleRefreshAttemptsByRoute = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* metadata 조회, 입력 검증, HTTP routing, Tool client와 경계 로그 협력 객체를 주입받습니다.
|
||||
*/
|
||||
public ToolExecutionService(
|
||||
ToolRegistryService registryService,
|
||||
ToolArgumentValidator argumentValidator,
|
||||
ToolRoutingService routingService,
|
||||
ToolClient toolClient,
|
||||
TraceLogger traceLogger) {
|
||||
this.registryService = registryService;
|
||||
this.argumentValidator = argumentValidator;
|
||||
this.routingService = routingService;
|
||||
this.toolClient = toolClient;
|
||||
this.traceLogger = traceLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Builder가 이름으로 지정한 단일 Tool을 조회·검증·routing한 뒤 한 번 실행합니다. 처리 순서는 Registry 조회 → inputSchema 검증 → endpoint/timeout 확정 → ToolClient 호출이며, 호출 전후에는
|
||||
* payload를 제외한 Tool 이름·버전·상태·소요 시간만 기록합니다. ToolClient 실패는 실행 종류별 {@link JsonRpcException}으로 바꾸고 최종 {@code isError} 변환은 handler에 맡깁니다.
|
||||
*/
|
||||
public Result execute(ToolCall call, McpRequestContext context) {
|
||||
ToolMetadata metadata = registryService.findEnabledTool(context.routeKey(), call.toolName());
|
||||
argumentValidator.validate(call, metadata);
|
||||
ToolRequest toolRequest = routingService.route(call, metadata);
|
||||
traceLogger.event(
|
||||
"tool_http_request_started",
|
||||
"toolName",
|
||||
toolRequest.toolName(),
|
||||
"version",
|
||||
toolRequest.version());
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
ToolResponse response = toolClient.execute(toolRequest, context);
|
||||
double duration = elapsedMillis(started);
|
||||
traceLogger.event(
|
||||
"tool_http_response_received",
|
||||
"toolName",
|
||||
toolRequest.toolName(),
|
||||
"statusCode",
|
||||
response.statusCode(),
|
||||
"durationMillis",
|
||||
duration);
|
||||
return new Result(response.data(), duration);
|
||||
} catch (ToolClientException exception) {
|
||||
traceLogger.error("tool_http_request_failed", exception, "toolName", toolRequest.toolName());
|
||||
refreshRouteOnStaleToolSignal(context.routeKey(), toolRequest, exception);
|
||||
throw mapException(exception, toolRequest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Service가 404/410을 반환하면 현재 route의 in-memory snapshot이 오래되었을 수 있으므로 즉시 registry refresh를 시도합니다.
|
||||
* 현재 tools/call 결과는 원래 upstream 실패로 유지하고, refresh 실패는 로그로만 남겨 기존 정상 snapshot을 비우지 않습니다.
|
||||
* route별 cooldown을 둬 삭제된 Tool을 여러 Agent가 동시에 호출할 때 manifest 호출이 폭증하지 않게 합니다.
|
||||
*/
|
||||
private void refreshRouteOnStaleToolSignal(String routeKey, ToolRequest request, ToolClientException exception) {
|
||||
if (!isStaleToolSignal(exception) || !claimStaleRefreshSlot(routeKey)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
registryService.refresh(routeKey);
|
||||
traceLogger.event(
|
||||
"tool_registry_refresh_triggered_by_stale_tool",
|
||||
"routeKey",
|
||||
routeKey,
|
||||
"toolName",
|
||||
request.toolName());
|
||||
} catch (RuntimeException refreshFailure) {
|
||||
traceLogger.error(
|
||||
"tool_registry_refresh_after_stale_tool_failed",
|
||||
refreshFailure,
|
||||
"routeKey",
|
||||
routeKey,
|
||||
"toolName",
|
||||
request.toolName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* upstream HTTP 상태가 삭제되었거나 더 이상 제공되지 않는 Tool을 의미하는지 판단합니다.
|
||||
* 404와 410만 stale snapshot 보정 신호로 취급하고, 인증·권한·서버 오류는 기존 실행 실패로만 처리합니다.
|
||||
*/
|
||||
private boolean isStaleToolSignal(ToolClientException exception) {
|
||||
java.util.OptionalInt status = exception.httpStatusCode();
|
||||
return status.isPresent() && (status.getAsInt() == 404 || status.getAsInt() == 410);
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 route에 대한 stale refresh가 짧은 시간 안에 반복되지 않도록 best-effort로 slot을 확보합니다.
|
||||
* 동시 요청에서는 먼저 들어온 한 요청만 refresh를 수행하고 나머지는 기존 실패 응답만 반환합니다.
|
||||
*/
|
||||
private boolean claimStaleRefreshSlot(String routeKey) {
|
||||
String key = routeKey == null ? "" : routeKey;
|
||||
long now = System.nanoTime();
|
||||
Long previous = staleRefreshAttemptsByRoute.get(key);
|
||||
if (previous != null && now - previous < STALE_REFRESH_COOLDOWN_NANOS) {
|
||||
return false;
|
||||
}
|
||||
staleRefreshAttemptsByRoute.put(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* System.nanoTime 기준 경과 시간을 밀리초 단위로 계산합니다.
|
||||
*/
|
||||
private double elapsedMillis(long startedNanos) {
|
||||
return (System.nanoTime() - startedNanos) / 1_000_000.0d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool client 실패 종류를 timeout·권한·실행 JSON-RPC 코드로 일관되게 변환합니다.
|
||||
*/
|
||||
private JsonRpcException mapException(ToolClientException exception, ToolRequest request) {
|
||||
JsonRpcErrorCode code =
|
||||
switch (exception.kind()) {
|
||||
case TIMEOUT -> JsonRpcErrorCode.TOOL_TIMEOUT;
|
||||
case UNAUTHORIZED -> JsonRpcErrorCode.UNAUTHORIZED;
|
||||
case FORBIDDEN -> JsonRpcErrorCode.FORBIDDEN;
|
||||
case EXECUTION -> JsonRpcErrorCode.TOOL_EXECUTION_ERROR;
|
||||
};
|
||||
return new JsonRpcException(
|
||||
code,
|
||||
request.toolName() + "@" + request.version() + ": " + exception.getMessage(),
|
||||
exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Service가 반환한 정규화된 본문과 MCP가 측정한 실행 시간을 handler에 전달하는 불변 결과입니다. {@link io.shinhanlife.dap.biz.mcp.method.ToolsCallHandler}가 이를 MCP text content와
|
||||
* {@code searchTime}으로 변환합니다.
|
||||
*/
|
||||
public record Result(JsonNode data, double durationMillis) {
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
|
||||
/**
|
||||
* MCP Server가 Agent Builder로 비동기 알림을 보낼 때 사용할 JSON-RPC 2.0 notification envelope입니다.
|
||||
* 일반 request handler가 즉시 HTTP 응답으로 반환하는 객체가 아니라 Registry refresh 같은 배경 처리 단계에서 생성되며,
|
||||
* 실제 전송은 SSE/Streamable HTTP 같은 transport 확장 지점이 담당합니다. 주요 의존성은 JSON-RPC version 상수와
|
||||
* notification method 계약입니다.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record JsonRpcNotification(String jsonrpc, String method, Object params) {
|
||||
|
||||
public static final String METHOD_TOOLS_LIST_CHANGED = "notifications/tools/list_changed";
|
||||
|
||||
/**
|
||||
* Tool catalog snapshot 변경을 Agent Builder에 알리는 표준 MCP notification을 생성합니다.
|
||||
* notification은 응답 id가 없으며, 최신 목록은 Agent Builder가 이후 {@code tools/list}를 다시 호출해 가져갑니다.
|
||||
*/
|
||||
public static JsonRpcNotification toolsListChanged() {
|
||||
return new JsonRpcNotification(McpSchema.JSONRPC_VERSION, METHOD_TOOLS_LIST_CHANGED, null);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.jsonrpc;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 검증을 통과한 JSON-RPC 2.0 요청의 불변 내부 표현입니다. {@link JsonRpcRequestParser}가 만들고 controller와 method handler가 사용하며, {@code id} 유무로 notification 여부를 판단합니다. HTTP 헤더나 인증
|
||||
* 정보는 포함하지 않고 request context가 별도로 관리합니다.
|
||||
*/
|
||||
public record JsonRpcRequest(String method, JsonNode params, JsonNode id) {
|
||||
|
||||
/**
|
||||
* id가 없는 요청인지 확인하여 JSON-RPC notification 여부를 판단합니다.
|
||||
*/
|
||||
public boolean notification() {
|
||||
return id == null || id.isNull();
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.method;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* MCP lifecycle의 {@code initialize} 요청을 처리해 서버 정보, capability, 선택 protocol version을 응답합니다. 설정된 MCP POST endpoint에서 {@code McpController}가 method별로 이 handler를 선택하며,
|
||||
* 새 mcp-session-id 헤더 발급은 HTTP transport의 책임입니다. 주요 의존성은 서버명·버전·프로토콜 설정을 제공하는 {@link McpProperties}, MCP SDK 표준 초기화 모델, JSON-RPC 응답 envelope입니다.
|
||||
*/
|
||||
@Component
|
||||
public class InitializeHandler implements McpMethodHandlerRegistry.Handler {
|
||||
|
||||
private final McpProperties properties;
|
||||
|
||||
/**
|
||||
* initialize 응답에 사용할 서버 정보와 protocol 설정을 주입받습니다.
|
||||
*/
|
||||
public InitializeHandler(McpProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 handler가 담당하는 MCP method 이름인 `initialize`를 반환합니다.
|
||||
*/
|
||||
@Override
|
||||
public String method() {
|
||||
return McpSchema.METHOD_INITIALIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Builder에 protocol version, 서버 정보, 지원 capability를 알려 주는 initialize 결과를 만듭니다. 요청 ID를 그대로 응답에 넣어 JSON-RPC correlation을 유지합니다.
|
||||
*/
|
||||
@Override
|
||||
public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) {
|
||||
String routeKey = context == null || context.routeKey() == null || context.routeKey().isBlank()
|
||||
? null : context.routeKey().trim().toLowerCase(java.util.Locale.ROOT);
|
||||
String serverName = routeKey == null
|
||||
? properties.server().name()
|
||||
: properties.server().name() + "-" + routeKey;
|
||||
String serverTitle = routeKey == null
|
||||
? properties.server().title()
|
||||
: properties.server().title() + " (" + routeKey.toUpperCase(java.util.Locale.ROOT) + ")";
|
||||
McpSchema.Implementation serverInfo =
|
||||
McpSchema.Implementation.builder(serverName, properties.server().version())
|
||||
.title(serverTitle)
|
||||
.build();
|
||||
McpSchema.ServerCapabilities capabilities =
|
||||
McpSchema.ServerCapabilities.builder().tools(true).build();
|
||||
McpSchema.InitializeResult result =
|
||||
McpSchema.InitializeResult.builder(
|
||||
properties.protocol().preferredVersion(), capabilities, serverInfo)
|
||||
.build();
|
||||
return JsonRpcResponse.success(request.id(), result);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.method;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.context.McpRequestContext;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcResponse;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* AgentBuilder가 initialize 완료 뒤 보내는 {@code notifications/initialized} 알림을 수신하는 stateless handler입니다. 이 요청은 서버 상태나 세션을 만들지 않고 {@code McpController}가 HTTP 202으로
|
||||
* 마무리합니다. 별도 협력 객체 없이 표준 notification acknowledgement만 반환합니다.
|
||||
*/
|
||||
@Component
|
||||
public class InitializedNotificationHandler implements McpMethodHandlerRegistry.Handler {
|
||||
|
||||
/**
|
||||
* 이 handler가 담당하는 `notifications/initialized` method 이름을 반환합니다.
|
||||
*/
|
||||
@Override
|
||||
public String method() {
|
||||
return McpSchema.METHOD_NOTIFICATION_INITIALIZED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Builder의 initialize 완료 notification을 수용합니다. 서버 상태를 생성하지 않으며 {@code McpController}가 HTTP 202 빈 응답으로 최종 처리합니다.
|
||||
*/
|
||||
@Override
|
||||
public JsonRpcResponse handle(JsonRpcRequest request, McpRequestContext context) {
|
||||
return JsonRpcResponse.success(null, Map.of());
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Tool discovery 첫 시도와 in-memory snapshot 적재 여부로 readiness를 판정하는 health indicator입니다. MCP 요청을 직접 처리하지 않으며 {@code /actuator/health/readiness}의 readiness group에서
|
||||
* 평가됩니다. 원천 조회가 실패해도 memory 또는 Redis의 last-good snapshot이 있으면 서비스 가능 상태로 인정하지만, 사용할 snapshot이 전혀 없는 Pod은 트래픽을 받지 않습니다. 주요 협력 객체는 기동 조회 완료 시점을 제공하는
|
||||
* {@link ToolRegistryRefreshScheduler}와 요청 경로의 snapshot을 소유하는 {@link ToolRegistryService}입니다.
|
||||
*/
|
||||
@Component
|
||||
public class ToolCatalogHealthIndicator implements HealthIndicator {
|
||||
|
||||
private final ToolRegistryRefreshScheduler scheduler;
|
||||
private final ToolRegistryService registryService;
|
||||
|
||||
/**
|
||||
* 기동 preload 시점과 usable Tool snapshot을 함께 확인할 협력 객체를 주입받습니다.
|
||||
*/
|
||||
public ToolCatalogHealthIndicator(
|
||||
ToolRegistryRefreshScheduler scheduler, ToolRegistryService registryService) {
|
||||
this.scheduler = scheduler;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기동 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();
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.config.McpProperties.Bundle;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import io.shinhanlife.dap.biz.mcp.registry.ToolBundleDiscovery.BundleResult;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Portal Registry API를 Tool Server endpoint 원천으로 사용하는 adapter입니다.
|
||||
* MCP 요청을 직접 처리하지 않고 {@link ToolRegistryService}의 기동 preload와 주기 refresh에서 호출되며, 포털 응답을 기존 {@link ToolBundleDiscovery} 검증 경로로 연결합니다.
|
||||
* 주요 의존성은 포털 조회용 {@link RestClient}, Tool Service manifest 검증을 담당하는 {@link ToolBundleDiscovery}, 그리고 portal/discovery 정책을 제공하는 {@link McpProperties}입니다.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "mcp.portal", name = "enabled", havingValue = "true")
|
||||
public class PortalToolRegistryClient implements ToolRegistryClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PortalToolRegistryClient.class);
|
||||
|
||||
private final RestClient restClient;
|
||||
private final McpProperties properties;
|
||||
private final ToolBundleDiscovery discovery;
|
||||
private final Optional<RedisPortalRegistryCache> redisPortalRegistryCache;
|
||||
private final java.util.concurrent.atomic.AtomicReference<String> lastPortalRevision =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
private final java.util.concurrent.ConcurrentMap<String, List<Bundle>> bundlesByRoute =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Portal Registry 조회 client와 기존 Tool Service manifest discovery를 주입받습니다.
|
||||
* 포털 응답은 이 adapter에서만 실행 주소 정보로 변환하고, 실제 manifest 검증은 기존 discovery 계약을 재사용합니다.
|
||||
*/
|
||||
public PortalToolRegistryClient(
|
||||
@Qualifier("manifestRestClient") RestClient restClient,
|
||||
McpProperties properties,
|
||||
ToolBundleDiscovery discovery,
|
||||
Optional<RedisPortalRegistryCache> redisPortalRegistryCache) {
|
||||
this.restClient = restClient;
|
||||
this.properties = properties;
|
||||
this.discovery = discovery;
|
||||
this.redisPortalRegistryCache = redisPortalRegistryCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 registry에서 현재 route 목록을 확인하고 지정 route의 Tool Service manifest를 다시 조회합니다.
|
||||
* 포털은 endpoint 목록의 원천으로만 사용하며, route가 비어 있거나 없으면 registry unavailable 오류로 처리합니다.
|
||||
*/
|
||||
@Override
|
||||
public List<ToolMetadata> fetchTools(String routeKey) {
|
||||
String normalizedRouteKey = normalizeRouteKey(routeKey);
|
||||
ensurePortalRegistryLoaded();
|
||||
List<Bundle> bundles = bundlesByRoute.get(normalizedRouteKey);
|
||||
if (bundles == null) {
|
||||
throw unavailable("Portal registry route is not found: " + normalizedRouteKey);
|
||||
}
|
||||
return fetchRouteTools(normalizedRouteKey, bundles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 전체 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) -> {
|
||||
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 규칙을 호출자에게 전달합니다.
|
||||
*/
|
||||
@Override
|
||||
public boolean refreshSourceRegistry() {
|
||||
String registryUrl = registryUrl("");
|
||||
JsonNode registry = loadPortalRegistryWithFallback(registryUrl);
|
||||
return registerPortalRegistry(registryUrl, registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal API를 먼저 조회하고, 실패 시 기존 memory endpoint snapshot 또는 Redis fallback으로 대체합니다.
|
||||
* 이미 memory가 있으면 Redis를 읽지 않고 기존 snapshot을 유지하며, cold start처럼 memory가 없을 때만 Redis registry JSON을 마지막 fallback으로 사용합니다.
|
||||
*/
|
||||
private JsonNode loadPortalRegistryWithFallback(String registryUrl) {
|
||||
try {
|
||||
return portalRegistry(registryUrl);
|
||||
} catch (RuntimeException exception) {
|
||||
if (!bundlesByRoute.isEmpty()) {
|
||||
log.warn(
|
||||
"Portal registry refresh failed; keeping in-memory endpoint snapshot. reason={}",
|
||||
exception.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
Optional<JsonNode> cached = redisPortalRegistryCache.flatMap(RedisPortalRegistryCache::loadRegistry);
|
||||
if (cached.isPresent()) {
|
||||
log.info("Portal registry loaded from Redis fallback. key={}",
|
||||
redisPortalRegistryCache.map(RedisPortalRegistryCache::key).orElse("<unavailable>"));
|
||||
return cached.get();
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal 또는 Redis에서 읽은 registry JSON을 route별 endpoint memory snapshot으로 반영합니다.
|
||||
* route key는 포털 응답 안에 반드시 있어야 하며, 설정 기본 route로 보정하지 않습니다.
|
||||
*/
|
||||
private boolean registerPortalRegistry(String registryUrl, JsonNode registry) {
|
||||
if (registry == null) {
|
||||
return false;
|
||||
}
|
||||
boolean changed = logPortalRegistryIfChanged(registryUrl, registry);
|
||||
JsonNode routes = registry.path("routes");
|
||||
if (!routes.isArray()) {
|
||||
String routeKey = normalizeRouteKey(required(registry, "routeKey"));
|
||||
bundlesByRoute.put(routeKey, toBundles(registry.path("toolServices")));
|
||||
return changed;
|
||||
}
|
||||
Map<String, List<Bundle>> updated = new LinkedHashMap<>();
|
||||
for (JsonNode route : routes) {
|
||||
String routeKey = normalizeRouteKey(required(route, "routeKey"));
|
||||
updated.put(routeKey, toBundles(route.path("toolServices")));
|
||||
}
|
||||
bundlesByRoute.keySet().removeIf(routeKey -> !updated.containsKey(routeKey));
|
||||
bundlesByRoute.putAll(updated);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 registry URL을 호출하고 기본 응답 shape를 검증합니다.
|
||||
* 원문 payload를 오류 메시지에 포함하지 않고, 호출 실패는 Registry unavailable 예외로 상위 refresh 정책에 전달합니다.
|
||||
*/
|
||||
private JsonNode portalRegistry(String registryUrl) {
|
||||
JsonNode registry = restClient.get()
|
||||
.uri(registryUrl)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
if (registry == null) {
|
||||
throw unavailable("Portal registry response is invalid");
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 전체 registry 응답을 최초 수신하거나 {@code registryRevision}이 바뀐 경우에만 INFO 로그로 남깁니다.
|
||||
* 로컬 검증용 로그이므로 endpoint와 Tool Server 설정을 포함한 응답 JSON 전체를 그대로 보여 줍니다.
|
||||
*/
|
||||
private boolean logPortalRegistryIfChanged(String registryUrl, JsonNode registry) {
|
||||
String revision = registry.path("registryRevision").asText("");
|
||||
String previous = lastPortalRevision.get();
|
||||
boolean changed = previous == null || !previous.equals(revision);
|
||||
if (changed && lastPortalRevision.compareAndSet(previous, revision)) {
|
||||
log.info(
|
||||
"Portal registry response accepted. registryUrl={} previousRevision={} registryRevision={} body={}",
|
||||
registryUrl,
|
||||
previous,
|
||||
revision,
|
||||
registry.toPrettyString());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 route의 endpoint 목록을 route별 Tool metadata snapshot으로 변환합니다.
|
||||
* 각 Tool Service manifest 조회 결과는 기존 discovery 검증과 merge 규칙을 통과해야 합니다.
|
||||
*/
|
||||
private List<ToolMetadata> fetchRouteTools(String routeKey, List<Bundle> bundles) {
|
||||
List<BundleResult> results = discovery.discoverAll(bundles);
|
||||
return merge(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최초 기동 또는 cache가 비어 있는 요청 시점에 포털 registry를 조회합니다.
|
||||
* 이후 manifest 주기 refresh는 저장된 endpoint 목록만 사용하므로 포털 API와 Tool Server manifest 호출 주기를 분리합니다.
|
||||
*/
|
||||
private void ensurePortalRegistryLoaded() {
|
||||
if (bundlesByRoute.isEmpty()) {
|
||||
refreshSourceRegistry();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트 요청 경로나 포털 응답에서 받은 route key를 메모리 snapshot 조회 key로 정규화합니다.
|
||||
* route key가 비어 있으면 기본 route로 보정하지 않고 registry unavailable 오류로 처리해 잘못된 단일 진입점 호출을 드러냅니다.
|
||||
*/
|
||||
private String normalizeRouteKey(String routeKey) {
|
||||
if (routeKey == null || routeKey.isBlank()) {
|
||||
throw unavailable("Portal registry routeKey is required");
|
||||
}
|
||||
return routeKey.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 Registry URL을 호출 주소로 변환합니다.
|
||||
* URL에 {@code {route}} placeholder가 있으면 치환하고, 없으면 전체 registry 조회 URL로 그대로 사용합니다.
|
||||
*/
|
||||
private String registryUrl(String routeKey) {
|
||||
String configured = properties.portal().registryUrl();
|
||||
return configured.contains("{route}") ? configured.replace("{route}", routeKey) : configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털의 active Tool Service 목록을 기존 ToolBundleDiscovery가 이해하는 bundle 선언으로 변환합니다.
|
||||
* service domain, manifest path, 실행 base path를 안정적인 URL 조합으로 정규화하며 active 서비스가 없으면 갱신을 거부합니다.
|
||||
*/
|
||||
private List<Bundle> toBundles(JsonNode services) {
|
||||
List<Bundle> bundles = new ArrayList<>();
|
||||
for (JsonNode service : services) {
|
||||
if (!"ACTIVE".equalsIgnoreCase(service.path("status").asText("ACTIVE"))) {
|
||||
continue;
|
||||
}
|
||||
String serviceDomain = trimTrailingSlash(required(service, "serviceDomain"));
|
||||
String manifestPath = normalizePath(required(service, "manifestPath"));
|
||||
String executeBasePath = normalizeOptionalPath(service.path("executeBasePath").asText(""));
|
||||
Map<String, String> toolEndpoints = new LinkedHashMap<>();
|
||||
JsonNode endpointNode = service.path("toolEndpoints");
|
||||
if (endpointNode.isObject()) {
|
||||
endpointNode.fields().forEachRemaining(entry ->
|
||||
toolEndpoints.put(entry.getKey(), normalizePath(entry.getValue().asText())));
|
||||
}
|
||||
bundles.add(new Bundle(
|
||||
required(service, "serviceKey"),
|
||||
serviceDomain + manifestPath,
|
||||
trimTrailingSlash(serviceDomain + executeBasePath),
|
||||
service.path("namePrefix").asText(""),
|
||||
true,
|
||||
null,
|
||||
toolEndpoints));
|
||||
}
|
||||
if (bundles.isEmpty()) {
|
||||
throw unavailable("Portal registry has no active Tool Service");
|
||||
}
|
||||
return List.copyOf(bundles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Service별 discovery 결과를 하나의 MCP Tool catalog로 병합합니다.
|
||||
* 사용 가능한 성공본이 없는 서비스, Tool name 중복, 전체 상한 초과는 불완전한 snapshot을 만들지 않도록 실패 처리합니다.
|
||||
*/
|
||||
private List<ToolMetadata> merge(List<BundleResult> results) {
|
||||
if (results.stream().anyMatch(result -> !result.usableSnapshot())) {
|
||||
throw unavailable("At least one Portal Tool Service has no usable snapshot");
|
||||
}
|
||||
List<BundleTool> candidates = new ArrayList<>();
|
||||
for (BundleResult result : results) {
|
||||
result.tools().forEach(tool -> candidates.add(new BundleTool(result.bundleId(), tool)));
|
||||
}
|
||||
candidates.sort(Comparator.comparing(BundleTool::bundleId).thenComparing(entry -> entry.tool().name()));
|
||||
|
||||
int maxTotal = properties.discovery().maxToolsTotal();
|
||||
Set<String> names = new HashSet<>();
|
||||
List<ToolMetadata> merged = new ArrayList<>();
|
||||
for (BundleTool candidate : candidates) {
|
||||
if (!names.add(candidate.tool().name())) {
|
||||
throw unavailable("Duplicate Tool name across Portal services: " + candidate.tool().name());
|
||||
}
|
||||
if (merged.size() >= maxTotal) {
|
||||
throw unavailable("Tool catalog exceeds maxToolsTotal: " + maxTotal);
|
||||
}
|
||||
merged.add(candidate.tool());
|
||||
}
|
||||
return List.copyOf(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 응답의 필수 문자열 필드를 읽고 누락 시 registry 구성 오류로 변환합니다.
|
||||
* 원문 payload를 오류 메시지에 포함하지 않아 포털 응답의 민감 정보가 로그로 노출되지 않게 합니다.
|
||||
*/
|
||||
private String required(JsonNode node, String field) {
|
||||
String value = node.path(field).asText(null);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw unavailable("Portal Tool Service field is required: " + field);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앞에 slash가 붙은 manifest path를 service domain 뒤에 붙일 수 있는 내부 경로 형태로 정규화합니다.
|
||||
*/
|
||||
private String normalizePath(String path) {
|
||||
return "/" + path.replaceAll("^/+", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 포털 응답의 선택 실행 base path를 domain 뒤에 붙일 수 있는 경로로 정규화합니다.
|
||||
* 빈 값은 root 경로에 Tool name을 바로 붙이는 실행 계약을 의미합니다.
|
||||
*/
|
||||
private String normalizeOptionalPath(String path) {
|
||||
if (path == null || path.isBlank() || "/".equals(path)) {
|
||||
return "";
|
||||
}
|
||||
return "/" + path.replaceAll("^/+", "").replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* service domain 또는 실행 base endpoint 끝의 중복 slash를 제거해 routing 결과를 안정화합니다.
|
||||
*/
|
||||
private String trimTrailingSlash(String value) {
|
||||
return value.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* registry 원천 오류를 표준 JSON-RPC registry unavailable 예외로 변환합니다.
|
||||
*/
|
||||
private JsonRpcException unavailable(String message) {
|
||||
return new JsonRpcException(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 정렬 중 bundle id와 Tool metadata를 함께 보관하는 내부 값 객체입니다.
|
||||
*/
|
||||
private record BundleTool(String bundleId, ToolMetadata tool) {
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.jsonrpc.JsonRpcNotification;
|
||||
|
||||
/**
|
||||
* route별 Tool catalog snapshot이 실제로 변경됐음을 transport 계층에 전달하는 내부 도메인 이벤트입니다.
|
||||
* Registry refresh 배경 처리에서 발행되며 직접 Agent Builder 요청을 처리하지 않습니다. 이벤트 소비자는 route별 연결 상태를
|
||||
* 알고 있는 SSE/Streamable HTTP 전송 계층이며, payload는 Agent Builder로 보낼 표준 JSON-RPC notification입니다.
|
||||
*/
|
||||
public record ToolListChangedEvent(String routeKey, JsonRpcNotification notification) {
|
||||
|
||||
/**
|
||||
* 변경된 route key와 표준 {@code notifications/tools/list_changed} envelope를 묶습니다.
|
||||
* route key는 전송 계층이 같은 route로 initialize한 Agent Builder 연결만 골라 알릴 때 사용합니다.
|
||||
*/
|
||||
public ToolListChangedEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* route별 Tool 목록 변경 이벤트를 생성합니다.
|
||||
* notification 본문에는 route를 넣지 않고, 표준 MCP method만 담아 Agent Builder가 다시 {@code tools/list}를 호출하게 합니다.
|
||||
*/
|
||||
public static ToolListChangedEvent forRoute(String routeKey) {
|
||||
return new ToolListChangedEvent(routeKey, JsonRpcNotification.toolsListChanged());
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고,
|
||||
* {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. 생성 시점에 {@link ToolSchemaReferencePolicy}와 {@link ToolSchemaPatternPolicy}로
|
||||
* {@code inputSchema}를 검사하므로, 어느 조회 경로로 들어온 metadata든 문서 밖을 가리키는 참조나 되돌아오는 데 오래 걸리는 정규식을 담은 채로는 만들어지지 않습니다.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ToolMetadata(
|
||||
String name,
|
||||
String version,
|
||||
String description,
|
||||
String endpoint,
|
||||
JsonNode inputSchema,
|
||||
Integer timeoutMillis,
|
||||
boolean enabled,
|
||||
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,
|
||||
String description,
|
||||
String endpoint,
|
||||
JsonNode inputSchema,
|
||||
Integer timeoutMillis,
|
||||
boolean enabled,
|
||||
JsonNode publicDefinition) {
|
||||
this(name, version, description, endpoint, inputSchema, timeoutMillis, enabled, publicDefinition, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool별 timeout이 설정되어 있으면 사용하고, 없으면 공통 기본 timeout을 반환합니다.
|
||||
*/
|
||||
public int effectiveTimeoutMillis(int defaultTimeoutMillis) {
|
||||
return timeoutMillis == null || timeoutMillis <= 0 ? defaultTimeoutMillis : timeoutMillis;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user