forked from kimhyungsik/ax_hub_mcp_tool
feat: expose tool timeout and retry metadata
All checks were successful
Deploy Tools / deploy (push) Successful in 1m27s
All checks were successful
Deploy Tools / deploy (push) Successful in 1m27s
This commit is contained in:
60
README.md
60
README.md
@@ -1,8 +1,8 @@
|
||||
# DAP WAS MCP Tool Pods
|
||||
# DASMT MCP Tool Pods
|
||||
|
||||
신한라이프 업무 기능을 MCP(Model Context Protocol) Tool로 제공하는 Java 멀티 모듈 프로젝트입니다. 각 업무 모듈은 독립 실행 가능한 Spring Boot 애플리케이션이며, MCP Streamable HTTP와 REST 실행 API를 함께 제공합니다.
|
||||
|
||||
이 저장소에는 DATMS(Gateway) 애플리케이션이 포함되어 있지 않습니다. DATMT는 Gateway로 Tool을 push 등록하지 않으며, 각 Pod가 `GET /tool-manifest`를 제공하면 DATMS가 이 Manifest를 pull하여 Tool 목록을 구성합니다. Tool 조회와 직접 실행은 각 Pod에서도 자체적으로 처리합니다.
|
||||
이 저장소에는 DATMS(Gateway) 애플리케이션이 포함되어 있지 않습니다. DASMT는 Gateway로 Tool을 push 등록하지 않으며, 각 Pod가 `GET /tool-manifest`를 제공하면 DATMS가 이 Manifest를 pull하여 Tool 목록을 구성합니다. Tool 조회와 직접 실행은 각 Pod에서도 자체적으로 처리합니다.
|
||||
|
||||
## 기술 기준
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- Spring Boot 3.5.11
|
||||
- MCP Java SDK 2.0.0
|
||||
- Spring AI Community MCP Annotations 0.9.0
|
||||
- Jackson 2.20.1
|
||||
- Jackson 2.19.4 (Spring Boot BOM)
|
||||
- MapStruct, MyBatis, Redis, Kafka, Resilience4j
|
||||
- JUnit 5
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
| `dat-was-lib` | MCP 서버, Tool 스캔·실행, Schema, Manifest, 보안, MCI/EAI/HTTP 연동 공통 기능 | - | - |
|
||||
| `dat-was-cus` | 고객·CRM·VOC·웹 콘텐츠 관리 Tool | 8084 | 51 |
|
||||
| `dat-was-sal` | 영업·청구·인수·동의·현장지원 Tool | 8082 | 52 |
|
||||
| `dat-was-pro` | 상품·계약·고객·GA 설계사 Tool | 8085 | 50 |
|
||||
| `dat-was-pro` | 상품 영역 Tool(개인고객 상세조회·모집수수료 공시) | 8085 | 2 |
|
||||
| `dat-was-sys` | IAM·시스템 상태·공지·점검·배포 Tool | 8086 | 51 |
|
||||
|
||||
업무 모듈에는 총 204개의 `@McpTool` 선언이 있습니다. 현재 업무 모듈의 `src/main/resources`에는 별도 `tool-definitions` YAML이 없으며, Tool 메타데이터는 어노테이션과 `@GrowToolHint`를 기준으로 생성됩니다.
|
||||
업무 모듈에는 총 156개의 `@McpTool` 선언이 있습니다. 현재 업무 모듈의 `src/main/resources`에는 별도 `tool-definitions` YAML이 없으며, Tool 메타데이터는 어노테이션과 `@GrowToolHint`를 기준으로 생성됩니다.
|
||||
|
||||
현재 업무 구현은 개발·연동 검증 단계입니다. `dat-was-sal`의 `cmm_claim_search`와 `cmm_memo_retriever`는 각각 MCI와 HTTP Client 흐름을 사용하며, 나머지 Tool은 외부 시스템을 변경하지 않는 모의 응답을 중심으로 구현되어 있습니다.
|
||||
|
||||
@@ -60,20 +60,20 @@ Tool Pod
|
||||
4. `ToolPodMcpToolSynchronizer`가 Tool을 MCP SDK 서버에 등록합니다.
|
||||
5. REST와 MCP 요청은 공통 `McpToolExecutionService`를 통해 실행됩니다.
|
||||
|
||||
DATMT 내부에는 `/registry/register`, `/registry/deregister` 호출이나 주기적인 Gateway heartbeat 전송이 없습니다. `ToolRegistryHeartbeatSender`라는 클래스명은 호환성을 위해 남아 있지만 현재 역할은 로컬 Tool 스캔과 메타데이터 생성뿐입니다.
|
||||
DASMT 내부에는 `/registry/register`, `/registry/deregister` 호출이나 주기적인 Gateway heartbeat 전송이 없습니다. `ToolRegistryHeartbeatSender`라는 클래스명은 호환성을 위해 남아 있지만 현재 역할은 로컬 Tool 스캔과 메타데이터 생성뿐입니다.
|
||||
|
||||
### DATMS 연동 방식
|
||||
|
||||
```text
|
||||
DATMS
|
||||
└─ GET {DATMT Pod URL}/tool-manifest
|
||||
└─ GET {DASMT Pod URL}/tool-manifest
|
||||
└─ bundleId + revision + tools[]
|
||||
└─ 각 Tool endpoint: {Pod URL}/mcp/{toolName}
|
||||
```
|
||||
|
||||
- `mcp.manifest.bundle-id`는 Manifest를 제공하는 Pod의 고유 식별자이며 필수입니다.
|
||||
- 현재 값은 `was-sal`, `was-cus`, `was-pro`, `was-sys`입니다.
|
||||
- DATMS에 설정한 bundle ID와 DATMT가 반환하는 `bundleId`가 일치해야 같은 Tool bundle로 관리됩니다.
|
||||
- DATMS에 설정한 bundle ID와 DASMT가 반환하는 `bundleId`가 일치해야 같은 Tool bundle로 관리됩니다.
|
||||
- `mcp.manifest.name-prefix`가 비어 있지 않으면 모든 Tool 이름이 해당 prefix로 시작해야 합니다.
|
||||
- Manifest 내용이 바뀌면 `revision`이 증가하며, `If-None-Match`가 일치하면 `304 Not Modified`를 반환합니다.
|
||||
|
||||
@@ -88,6 +88,7 @@ DATMS
|
||||
| 로컬 Tool 메타데이터 조회 | `GET` | `/mcp/api/v1/tools/local` |
|
||||
| Tool 직접 실행 | `POST` | `/mcp/{toolName}` |
|
||||
| Tool Manifest 조회 | `GET` | `/tool-manifest` |
|
||||
| 카테고리별 Tool Manifest 조회 | `GET` | `/tool-manifest/{categoryKey}` |
|
||||
|
||||
`GET /tool-manifest`는 `If-None-Match` 요청 헤더를 지원합니다. Manifest가 변경되지 않았으면 `304 Not Modified`를 반환합니다.
|
||||
|
||||
@@ -103,7 +104,7 @@ $headers = @{
|
||||
'X-Request-Id' = 'request-local-001'
|
||||
'X-Request-Time' = '2026-08-25T12:34:56+09:00'
|
||||
'X-Vrtl-Praf-No' = 'V100001'
|
||||
'X-App-Code' = 'DATMT'
|
||||
'X-App-Code' = 'DASMT'
|
||||
'X-Project-Code' = 'AXHUB'
|
||||
'X-User-Ip' = '10.0.0.10'
|
||||
'X-Caller-Ip' = '10.0.0.20'
|
||||
@@ -123,11 +124,11 @@ Invoke-RestMethod `
|
||||
|
||||
### 요청 헤더 계약
|
||||
|
||||
DATMS가 DATMT Tool Service를 호출할 때 사용하는 헤더는 다음과 같습니다. HTTP 헤더 이름은 대소문자를 구분하지 않지만, 문서와 구현에서는 아래 표기를 기준으로 사용합니다.
|
||||
DATMS가 DASMT Tool Service를 호출할 때 사용하는 헤더는 다음과 같습니다. HTTP 헤더 이름은 대소문자를 구분하지 않지만, 문서와 구현에서는 아래 표기를 기준으로 사용합니다.
|
||||
|
||||
| 헤더 | 필수 여부 | 용도 | 전달 동작 |
|
||||
|---|---|---|---|
|
||||
| `X-Tool-Server-API-Key` | 인증 설정 시 필수 | DATMS와 DATMT 사이의 Tool Server 인증 | `mcp.security.api-key` 또는 `api-keys`와 비교 |
|
||||
| `X-Tool-Server-API-Key` | 인증 설정 시 필수 | DATMS와 DASMT 사이의 Tool Server 인증 | `mcp.security.api-key` 또는 `api-keys`와 비교 |
|
||||
| `X-Guid` | 선택 | 업무 호출 상관관계 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 |
|
||||
| `X-Praf-No` | 선택 | 실제 사용자 사번 | 세션 조회와 하위 HTTP 호출로 전달 |
|
||||
| `X-Request-Id` | 선택 | 요청 추적 식별자 | 실행 로그, 성공 응답, 하위 HTTP 호출로 전달 |
|
||||
@@ -142,7 +143,7 @@ DATMS가 DATMT Tool Service를 호출할 때 사용하는 헤더는 다음과
|
||||
| `X-Agent-Id` | 선택 | 호출 Agent 식별자 | 하위 HTTP 호출로 전달 |
|
||||
| `mcp-session-id` | 선택 | MCP 세션 식별자 | 성공 응답과 하위 HTTP 호출로 전달 |
|
||||
|
||||
REST 경로 `/mcp/{toolName}`은 Controller가 위 헤더를 직접 읽습니다. MCP Streamable HTTP 경로 `/mcp`는 `McpRequestHeaderFilter`가 동일한 헤더를 `McpRequestHeaderContext`에 저장한 뒤 Tool 실행과 하위 HTTP 호출에 전달합니다. 헤더가 없는 하위 HTTP 호출에는 `X-ANONYMOUS-REQ: AXHUB-TOOL`이 설정됩니다.
|
||||
`McpRequestHeaderFilter`는 URI에 `/mcp`가 포함된 요청에서 위 헤더를 `McpRequestHeaderContext`에 저장하므로 REST `/mcp/{toolName}`과 MCP Streamable HTTP `/mcp`, `/mcp/message`에 모두 적용됩니다. REST 경로는 `BusinessToolController`도 동일한 헤더를 직접 읽어 실행 서비스에 전달합니다. 헤더가 없는 하위 HTTP 호출에는 `X-ANONYMOUS-REQ: AXHUB-TOOL`이 설정됩니다.
|
||||
|
||||
기존 `guid`, `employee-no`, `virtual-employee-no` 헤더는 지원하지 않습니다.
|
||||
|
||||
@@ -260,13 +261,15 @@ http://localhost:8086/swagger-ui/index.html
|
||||
| `PORT` | Pod 수신 포트 | 모듈별 기본 포트 |
|
||||
| `TOOL_SERVER_API_KEY` | DATMS가 `X-Tool-Server-API-Key`로 전달할 공통 인증 Key | `tool-server-key` |
|
||||
| `AXHUB_TOOL_URL` | Manifest의 Tool endpoint 생성에 사용할 Pod 외부 URL | `http://localhost:${server.port}` |
|
||||
| `AXHUB_GATEWAY_URL` | 프로필 및 Compose 호환용 Gateway URL. 현재 DATMT의 push 등록에는 사용하지 않음 | `http://localhost:8081` |
|
||||
| `AXHUB_GATEWAY_URL` | 프로필 및 Compose 호환용 Gateway URL. 현재 DASMT의 push 등록에는 사용하지 않음 | `http://localhost:8081` |
|
||||
| `SPRING_DATA_REDIS_HOST` | Redis 호스트 | `localhost` |
|
||||
| `SPRING_DATA_REDIS_PORT` | Redis 포트 | `6379` |
|
||||
| `GLOW_COMMUNICATION_MCI_HOST` | MCI 대상 호스트 | 프로필별 설정 |
|
||||
| `GLOW_COMMUNICATION_MCI_PORT` | MCI 대상 포트 | 프로필별 설정 |
|
||||
|
||||
`mcp.security.api-key`는 단일 DATMS 공통 Key를, `mcp.security.api-keys`는 `API Key → tenant ID` 형태의 다중 Key를 지원합니다. 둘 중 하나라도 설정되어 있으면 올바른 `X-Tool-Server-API-Key`가 없는 `/rpc/**`, `/mcp/**` 요청은 `401 Unauthorized`가 됩니다. 두 설정이 모두 비어 있을 때만 익명 요청을 허용합니다. 현재 네 업무 Pod의 기본 `application.yml`은 단일 Key를 설정하므로 API Key 없이 Tool을 호출할 수 없습니다.
|
||||
`mcp.security.api-key`는 단일 DATMS 공통 Key를, `mcp.security.api-keys`는 `API Key → tenant ID` 형태의 다중 Key를 지원합니다. `ApiKeyInterceptor`는 Spring MVC가 처리하는 `/rpc/**`, `/mcp/**` 요청에 적용되므로 REST `/mcp/{toolName}`과 `/mcp/api/v1/tools/local`은 올바른 `X-Tool-Server-API-Key`가 없으면 `401 Unauthorized`가 됩니다. 두 설정이 모두 비어 있을 때는 해당 MVC 요청을 익명으로 허용합니다. 현재 네 업무 Pod의 기본 `application.yml`은 단일 Key를 설정합니다.
|
||||
|
||||
반면 `/mcp`와 `/mcp/message`는 별도 Servlet으로 등록되어 Spring MVC `HandlerInterceptor`를 통과하지 않습니다. 현재 구현만으로는 이 두 MCP Streamable HTTP 경로에 `ApiKeyInterceptor` 인증이 적용되지 않으므로, 운영 배포 전 Servlet Filter 또는 전용 MCP 인증 계층을 추가해야 합니다.
|
||||
|
||||
## Scaffold
|
||||
|
||||
@@ -335,7 +338,7 @@ mcp:
|
||||
|
||||
## Docker Compose
|
||||
|
||||
`docker-compose.yml`은 DATMT의 네 업무 Pod만 정의합니다.
|
||||
`docker-compose.yml`은 DASMT의 네 업무 Pod만 정의합니다.
|
||||
|
||||
| 서비스 | 컨테이너 포트 | 호스트 포트 |
|
||||
|---|---:|---:|
|
||||
@@ -355,14 +358,14 @@ Compose 파일의 용도와 현재 주의점은 다음과 같습니다.
|
||||
|
||||
| 파일 | 용도 | 현재 소스 기준 주의점 |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | DATMT 네 Pod 단독 실행 | `gateway` 서비스가 없지만 push 등록이 제거되어 DATMT 시작에는 필요하지 않음 |
|
||||
| `docker-compose.local.yml` | DATMS와 DATMT의 로컬 통합 구성 | 두 저장소가 같은 상위 디렉터리에 있는 구조를 가정 |
|
||||
| `docker-compose.prod.yml` | DATMS와 DATMT의 개발 프로필 기반 OCI 구성 | 저장소의 runner 등록 토큰을 운영 Secret으로 분리해야 함 |
|
||||
| `docker-compose.yml` | DASMT 네 Pod 단독 실행 | `gateway` 서비스가 없지만 push 등록이 제거되어 DASMT 시작에는 필요하지 않음 |
|
||||
| `docker-compose.local.yml` | DATMS와 DASMT의 로컬 통합 구성 | 두 저장소가 같은 상위 디렉터리에 있는 구조를 가정 |
|
||||
| `docker-compose.prod.yml` | DATMS와 DASMT의 개발 프로필 기반 OCI 구성 | 저장소의 runner 등록 토큰을 운영 Secret으로 분리해야 함 |
|
||||
|
||||
`docker-compose.local.yml`과 `docker-compose.prod.yml`의 build context는 각각 `./dat-was-DATMS`, `./dat-was-datmt`입니다. 현재 파일 위치에서 사용할 때는 context 기준을 두 저장소의 상위 디렉터리로 맞춰야 합니다.
|
||||
`docker-compose.local.yml`과 `docker-compose.prod.yml`의 build context는 각각 `./dat-was-DATMS`, `./dat-was-dasmt`입니다. 현재 파일 위치에서 사용할 때는 context 기준을 두 저장소의 상위 디렉터리로 맞춰야 합니다.
|
||||
|
||||
```powershell
|
||||
# DATMT 저장소 디렉터리에서 실행
|
||||
# DASMT 저장소 디렉터리에서 실행
|
||||
docker compose --project-directory .. -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
@@ -370,7 +373,7 @@ docker compose --project-directory .. -f docker-compose.local.yml up --build
|
||||
|
||||
## OpenShift (Kubernetes) 배포
|
||||
|
||||
OpenShift 개발 환경용 Kustomize 매니페스트는 [`k8s/`](k8s/)에 있습니다. 대상은 DATMT의 네 Tool Pod뿐이며 DATMS(Gateway)의 Deployment·Service·Route는 이 저장소에서 만들지 않습니다.
|
||||
OpenShift 개발 환경용 Kustomize 매니페스트는 [`k8s/`](k8s/)에 있습니다. 대상은 DASMT의 네 Tool Pod뿐이며 DATMS(Gateway)의 Deployment·Service·Route는 이 저장소에서 만들지 않습니다.
|
||||
|
||||
```text
|
||||
k8s/
|
||||
@@ -389,7 +392,7 @@ k8s/
|
||||
| `was-pro` | 8085 | `http://was-pro:8085` |
|
||||
| `was-sys` | 8086 | `http://was-sys:8086` |
|
||||
|
||||
Gateway가 다른 namespace에 있으면 `was-sal.axhub-datmt-dev.svc`와 같은 FQDN을 사용하고, NetworkPolicy에서 Gateway namespace의 ingress를 별도로 허용해야 합니다.
|
||||
Gateway가 다른 namespace에 있으면 `was-sal.axhub-dasmt-dev.svc`와 같은 FQDN을 사용하고, NetworkPolicy에서 Gateway namespace의 ingress를 별도로 허용해야 합니다.
|
||||
|
||||
### 반영 전 설정
|
||||
|
||||
@@ -399,11 +402,11 @@ Gateway가 다른 namespace에 있으면 `was-sal.axhub-datmt-dev.svc`와 같은
|
||||
2. [`k8s/base/configmap.yaml`](k8s/base/configmap.yaml)의 `CHANGE_ME` MCI·EXTMCI·EAI 호스트
|
||||
3. 실제 Secret 값
|
||||
|
||||
`datmt-runtime-secrets` Secret은 Git에 저장하지 않고 OpenShift namespace에서 별도로 생성합니다. 최소한 `TOOL_SERVER_API_KEY`는 DATMS가 전달하는 `X-Tool-Server-API-Key`와 같은 값이어야 합니다. DB 계정·비밀번호, API Key, 인증서 비밀번호 등도 이 Secret으로 관리합니다.
|
||||
`dasmt-runtime-secrets` Secret은 Git에 저장하지 않고 OpenShift namespace에서 별도로 생성합니다. 최소한 `TOOL_SERVER_API_KEY`는 DATMS가 전달하는 `X-Tool-Server-API-Key`와 같은 값이어야 합니다. DB 계정·비밀번호, API Key, 인증서 비밀번호 등도 이 Secret으로 관리합니다.
|
||||
|
||||
```powershell
|
||||
# runtime-secrets.env는 저장소 밖에 보관합니다.
|
||||
oc -n axhub-datmt-dev create secret generic datmt-runtime-secrets `
|
||||
oc -n axhub-dasmt-dev create secret generic dasmt-runtime-secrets `
|
||||
--from-env-file=runtime-secrets.env
|
||||
```
|
||||
|
||||
@@ -419,7 +422,7 @@ Secret이 없으면 각 Deployment의 `envFrom.secretRef`를 해석할 수 없
|
||||
# OpenShift 로그인 및 project 선택 후
|
||||
oc kustomize k8s/overlays/dev
|
||||
oc apply -k k8s/overlays/dev
|
||||
oc get deployment,pod,svc -n axhub-datmt-dev
|
||||
oc get deployment,pod,svc -n axhub-dasmt-dev
|
||||
```
|
||||
|
||||
적용 전에는 `oc kustomize k8s/overlays/dev | oc apply --dry-run=client -f -`로 서버 측 스키마 검증을 수행합니다. 현재 매니페스트의 readiness/liveness probe는 TCP 포트 확인 방식입니다. Actuator health endpoint를 추가한 뒤에는 HTTP readiness/liveness probe로 변경하는 것을 권장합니다.
|
||||
@@ -430,8 +433,9 @@ oc get deployment,pod,svc -n axhub-datmt-dev
|
||||
|
||||
현재 구현을 운영 환경에 노출하기 전에 아래 항목을 반드시 점검해야 합니다.
|
||||
|
||||
- API Key 인터셉터는 `/rpc/**`, `/mcp/**`에 적용됩니다. 따라서 `/mcp`, `/mcp/{toolName}`, `/mcp/api/v1/tools/local`은 인증 대상입니다.
|
||||
- `/tool-manifest`는 위 인터셉터 경로 밖에 있어 현재 API Key 인증 대상이 아닙니다. 내부망·Ingress 정책 또는 별도 인증이 필요한지 운영 기준을 확인해야 합니다.
|
||||
- API Key 인터셉터는 Spring MVC의 `/rpc/**`, `/mcp/**` Handler에 적용됩니다. REST `/mcp/{toolName}`과 `/mcp/api/v1/tools/local`은 인증 대상입니다.
|
||||
- 별도 Servlet인 `/mcp`, `/mcp/message`는 MVC 인터셉터를 우회하므로 현재 API Key 인증 대상이 아닙니다. 운영 노출 전에 별도 인증을 추가해야 합니다.
|
||||
- `/tool-manifest`와 `/tool-manifest/{categoryKey}`는 위 인터셉터 경로 밖에 있어 현재 API Key 인증 대상이 아닙니다. 내부망·Ingress 정책 또는 별도 인증이 필요한지 운영 기준을 확인해야 합니다.
|
||||
- 네 업무 Pod의 기본 Key는 모두 `tool-server-key`입니다. 운영에서는 반드시 별도 Secret으로 교체하고 DATMS의 `X-Tool-Server-API-Key` 값과 일치시켜야 합니다.
|
||||
- 설정된 단일 Key와 다중 Key가 모두 없을 때만 익명 요청이 허용됩니다.
|
||||
- `mcp.security.tenant-domains`는 설정 객체에 바인딩되지만 Tool별 인가에 사용되지 않습니다.
|
||||
@@ -467,5 +471,5 @@ oc get deployment,pod,svc -n axhub-datmt-dev
|
||||
3. `validateMcpToolNames`와 모듈·전체 테스트를 실행합니다. Tool YAML 정의를 추가한 경우에는 V17 항목을 별도 검증합니다.
|
||||
4. 모듈 테스트와 전체 테스트를 실행합니다.
|
||||
5. 로컬 Pod에서 `/mcp/api/v1/tools/local`과 `/tool-manifest`를 확인합니다.
|
||||
6. DATMS의 bundle ID, Pod Manifest URL, Tool Server API Key가 DATMT 설정과 일치하는지 확인합니다.
|
||||
6. DATMS의 bundle ID, Pod Manifest URL, Tool Server API Key가 DASMT 설정과 일치하는지 확인합니다.
|
||||
7. REST와 MCP 양쪽에서 동일한 Tool 결과·요청 헤더 전달·오류 계약을 확인합니다.
|
||||
|
||||
@@ -29,6 +29,9 @@ public @interface GrowToolHint {
|
||||
String mappingId() default "";
|
||||
String inputSchemaResource() default "";
|
||||
String outputSchemaResource() default "";
|
||||
long timeoutMillis() default 5000L;
|
||||
boolean retryEnabled() default true;
|
||||
int retryMaxAttempts() default 3;
|
||||
|
||||
// Meta 정보 추가 (보고용 샘플)
|
||||
String displayDescription() default "";
|
||||
|
||||
@@ -7,6 +7,8 @@ import java.util.List;
|
||||
public record ToolManifestMeta(
|
||||
String version,
|
||||
long timeoutMillis,
|
||||
boolean retryEnabled,
|
||||
int retryMaxAttempts,
|
||||
boolean enabled,
|
||||
List<String> exampleQueries,
|
||||
List<String> tags,
|
||||
|
||||
@@ -21,7 +21,9 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ToolManifestService {
|
||||
|
||||
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
|
||||
private static final long DEFAULT_TIMEOUT_MILLIS = 5000L;
|
||||
private static final boolean DEFAULT_RETRY_ENABLED = true;
|
||||
private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3;
|
||||
private static final AtomicLong LAST_ISSUED_REVISION = new AtomicLong();
|
||||
private final Supplier<List<ToolMetadata>> toolSupplier;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -88,7 +90,9 @@ public class ToolManifestService {
|
||||
new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
|
||||
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
|
||||
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
|
||||
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(),
|
||||
positiveOrDefault(tool.getTimeoutMillis(), DEFAULT_TIMEOUT_MILLIS),
|
||||
tool.getRetryEnabled() == null ? DEFAULT_RETRY_ENABLED : tool.getRetryEnabled(),
|
||||
positiveOrDefault(tool.getRetryMaxAttempts(), DEFAULT_RETRY_MAX_ATTEMPTS),
|
||||
tool.getEnabled() == null || tool.getEnabled(),
|
||||
defaultList(tool.getExampleQueries()), defaultList(tool.getTags()),
|
||||
tool.getMciServiceId(), defaultList(tool.getRequiredEnvKeys()), tool.getOwnerOrg(),
|
||||
@@ -126,6 +130,14 @@ public class ToolManifestService {
|
||||
return lastRevision;
|
||||
}
|
||||
|
||||
private long positiveOrDefault(Long value, long defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private int positiveOrDefault(Integer value, int defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private String fingerprint(String bundleId, List<ToolManifestItem> tools) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("bundleId", bundleId, "tools", tools));
|
||||
|
||||
@@ -40,9 +40,20 @@ public final class ToolMetadataMcpMapper {
|
||||
put(meta, "legacy_interface_id", metadata.getMciServiceId());
|
||||
put(meta, "required_env_keys", metadata.getRequiredEnvKeys());
|
||||
put(meta, "owner_org", metadata.getOwnerOrg());
|
||||
meta.put("timeoutMillis", positiveOrDefault(metadata.getTimeoutMillis(), 5000L));
|
||||
meta.put("retryEnabled", metadata.getRetryEnabled() == null || metadata.getRetryEnabled());
|
||||
meta.put("retryMaxAttempts", positiveOrDefault(metadata.getRetryMaxAttempts(), 3));
|
||||
return Map.copyOf(meta);
|
||||
}
|
||||
|
||||
private static long positiveOrDefault(Long value, long defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static int positiveOrDefault(Integer value, int defaultValue) {
|
||||
return value != null && value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static void put(Map<String, Object> target, String key, Object value) {
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
target.put(key, text);
|
||||
|
||||
@@ -130,7 +130,9 @@ public class ToolRegistryHeartbeatSender {
|
||||
meta.setDisplayName(displayName);
|
||||
meta.setName(subToolName);
|
||||
meta.setSemver("1.0.0");
|
||||
meta.setTimeoutMillis(5000L);
|
||||
meta.setTimeoutMillis(hintAnnotation == null ? 5000L : positiveOrDefault(hintAnnotation.timeoutMillis(), 5000L));
|
||||
meta.setRetryEnabled(hintAnnotation == null || hintAnnotation.retryEnabled());
|
||||
meta.setRetryMaxAttempts(hintAnnotation == null ? 3 : positiveOrDefault(hintAnnotation.retryMaxAttempts(), 3));
|
||||
meta.setEnabled(true);
|
||||
meta.setDescription(functionAnnotation.description());
|
||||
|
||||
@@ -217,6 +219,14 @@ public class ToolRegistryHeartbeatSender {
|
||||
log.info(" [ToolScanner] 총 {}개 Tool 메타데이터 생성 완료", allScannedTools.size());
|
||||
}
|
||||
|
||||
private long positiveOrDefault(long value, long defaultValue) {
|
||||
return value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
private int positiveOrDefault(int value, int defaultValue) {
|
||||
return value > 0 ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @McpTool / @GrowToolHint 검색
|
||||
* <p>
|
||||
|
||||
@@ -231,7 +231,10 @@ public class ToolScaffolder {
|
||||
.append("\", description = \"").append(javaText(option(tool.description(), ""))).append("\")\n")
|
||||
.append(" @GrowToolHint(\n")
|
||||
.append(" requiresApproval = ").append(isMutation).append(",\n")
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n");
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n")
|
||||
.append(" timeoutMillis = 5000L,\n")
|
||||
.append(" retryEnabled = true,\n")
|
||||
.append(" retryMaxAttempts = 3,\n");
|
||||
if (tool.interfaceId() != null && !tool.interfaceId().isBlank()) {
|
||||
methods.append(" mappingId = \"").append(javaText(tool.interfaceId())).append("\",\n");
|
||||
}
|
||||
@@ -383,7 +386,10 @@ public class ToolScaffolder {
|
||||
.append("\", description = \"").append(javaText(option(tool.description(), ""))).append("\")\n")
|
||||
.append(" @GrowToolHint(\n")
|
||||
.append(" requiresApproval = ").append(isMutation).append(",\n")
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n");
|
||||
.append(" categoryKey = \"").append(tool.group().toLowerCase(Locale.ROOT)).append("\",\n")
|
||||
.append(" timeoutMillis = 5000L,\n")
|
||||
.append(" retryEnabled = true,\n")
|
||||
.append(" retryMaxAttempts = 3,\n");
|
||||
String mappingId = option(tool.interfaceId(), tool.httpApiName());
|
||||
if (mappingId != null && !mappingId.isBlank()) {
|
||||
declBuilder.append(" mappingId = \"").append(javaText(mappingId)).append("\",\n");
|
||||
@@ -550,7 +556,7 @@ public class ToolScaffolder {
|
||||
String schemaResourceDirectory = "classpath:tool-schemas/" + group.toLowerCase() + "/";
|
||||
String inputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-input-schema.json" : null;
|
||||
String outputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-output-schema.json" : null;
|
||||
String defaultWorkspace = "c:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-datmt";
|
||||
String defaultWorkspace = "c:\\eGovFrameDev-4.3.1-64bit\\workspace-egov\\dat-was-dasmt";
|
||||
String workspace = getOrAsk(args, 10, scanner, "11. 대상 프로젝트 워크스페이스 경로 (default: " + defaultWorkspace + "): ");
|
||||
if (workspace.trim().isEmpty()) {
|
||||
workspace = defaultWorkspace;
|
||||
@@ -766,6 +772,9 @@ public class ToolScaffolder {
|
||||
}
|
||||
sb.append(" requiresApproval = ").append(isMutation).append(",\n");
|
||||
sb.append(" categoryKey = \"").append(group.toLowerCase(Locale.ROOT)).append("\",\n");
|
||||
sb.append(" timeoutMillis = 5000L,\n");
|
||||
sb.append(" retryEnabled = true,\n");
|
||||
sb.append(" retryMaxAttempts = 3,\n");
|
||||
if (interfaceId != null && !interfaceId.isBlank()) {
|
||||
sb.append(" mappingId = \"").append(interfaceId).append("\",\n");
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ public class ToolMetadata {
|
||||
|
||||
@Builder.Default
|
||||
private Boolean retryEnabled = true;
|
||||
@Builder.Default
|
||||
private Integer retryMaxAttempts = 3;
|
||||
|
||||
@Builder.Default
|
||||
private Integer circuitBreakerFailureThreshold = 0;
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
'X-Request-Id': request,
|
||||
'X-Request-Time': new Date().toISOString(),
|
||||
'X-Vrtl-Praf-No': 'V100001',
|
||||
'X-App-Code': 'DATMT',
|
||||
'X-App-Code': 'DASMT',
|
||||
'X-Project-Code': 'AXHUB',
|
||||
'X-User-Ip': '127.0.0.1',
|
||||
'X-Caller-Ip': '127.0.0.1',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package io.shinhanlife.dat.lib.annotation;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrowToolHintTest {
|
||||
|
||||
@Test
|
||||
void exposesResilienceDefaults() throws Exception {
|
||||
assertEquals(5000L, GrowToolHint.class.getMethod("timeoutMillis").getDefaultValue());
|
||||
assertEquals(true, GrowToolHint.class.getMethod("retryEnabled").getDefaultValue());
|
||||
assertEquals(3, GrowToolHint.class.getMethod("retryMaxAttempts").getDefaultValue());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ class ToolManifestServiceTest {
|
||||
assertTrue(item.annotations().readOnlyHint());
|
||||
assertEquals("1.2.0", item.meta().version());
|
||||
assertEquals(3000, item.meta().timeoutMillis());
|
||||
assertEquals(false, item.meta().retryEnabled());
|
||||
assertEquals(5, item.meta().retryMaxAttempts());
|
||||
assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"),
|
||||
item.meta().exampleQueries());
|
||||
}
|
||||
@@ -95,6 +97,8 @@ class ToolManifestServiceTest {
|
||||
"required", List.of("contractNo"), "additionalProperties", false))
|
||||
.semver(version)
|
||||
.timeoutMillis(timeoutMillis)
|
||||
.retryEnabled(false)
|
||||
.retryMaxAttempts(5)
|
||||
.enabled(true)
|
||||
.readOnlyHint(true)
|
||||
.destructiveHint(false)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.shinhanlife.dat.lib.mcp;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import io.shinhanlife.dat.mcc.dto.ToolMetadata;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolMetadataMcpMapperTest {
|
||||
|
||||
@Test
|
||||
void exposesResilienceSettingsInMcpToolMeta() {
|
||||
ToolMetadata metadata = ToolMetadata.builder()
|
||||
.timeoutMillis(12000L)
|
||||
.retryEnabled(false)
|
||||
.retryMaxAttempts(5)
|
||||
.build();
|
||||
|
||||
Map<String, Object> meta = ToolMetadataMcpMapper.meta(metadata);
|
||||
|
||||
assertEquals(12000L, meta.get("timeoutMillis"));
|
||||
assertEquals(false, meta.get("retryEnabled"));
|
||||
assertEquals(5, meta.get("retryMaxAttempts"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import io.shinhanlife.dat.lib.annotation.GrowToolHint;
|
||||
import io.shinhanlife.dat.lib.config.McpProperties;
|
||||
import io.shinhanlife.dat.lib.util.ToolSchemaResolver;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
@@ -48,6 +49,18 @@ class ToolRegistryHeartbeatSenderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsResilienceValuesOnlyFromGrowToolHint() throws Exception {
|
||||
ToolRegistryHeartbeatSender sender = senderWithOneTool("http://127.0.0.1:1");
|
||||
|
||||
sender.init();
|
||||
|
||||
var metadata = sender.getAllScannedTools().getFirst();
|
||||
assertThat(metadata.getTimeoutMillis()).isEqualTo(12000L);
|
||||
assertThat(metadata.getRetryEnabled()).isFalse();
|
||||
assertThat(metadata.getRetryMaxAttempts()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private ToolRegistryHeartbeatSender senderWithOneTool(String gatewayUrl) throws Exception {
|
||||
ApplicationContext applicationContext = mock(ApplicationContext.class);
|
||||
when(applicationContext.getBeansOfType(Object.class)).thenReturn(Map.of("tool", new SampleTool()));
|
||||
@@ -87,6 +100,7 @@ class ToolRegistryHeartbeatSenderTest {
|
||||
|
||||
static class SampleTool {
|
||||
@McpTool(name = "test_sample_tool")
|
||||
@GrowToolHint(timeoutMillis = 12000L, retryEnabled = false, retryMaxAttempts = 1)
|
||||
void execute() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ class ToolScaffolderTest {
|
||||
String useCase = Files.readString(useCasePath);
|
||||
|
||||
assertTrue(useCase.contains("name = \"smp_employee_search\""), useCase);
|
||||
assertTrue(useCase.contains("timeoutMillis = 5000L"), useCase);
|
||||
assertTrue(useCase.contains("retryEnabled = true"), useCase);
|
||||
assertTrue(useCase.contains("retryMaxAttempts = 3"), useCase);
|
||||
assertTrue(useCase.contains("whenToUse = \"사용자가 이 업무 기능의 실행 또는 조회를 요청할 때 사용합니다.\""), useCase);
|
||||
assertTrue(useCase.contains("exampleQueries = {\"직원 조회 정보를 보여줘\""), useCase);
|
||||
assertTrue(useCase.contains("ownerOrg = \"MCP_TOOL\""), useCase);
|
||||
|
||||
Reference in New Issue
Block a user