diff --git a/README.md b/README.md index 41bf675de..51a7662a1 100644 --- a/README.md +++ b/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 결과·요청 헤더 전달·오류 계약을 확인합니다. diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/annotation/GrowToolHint.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/annotation/GrowToolHint.java index c5443fb05..936e26b9e 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/annotation/GrowToolHint.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/annotation/GrowToolHint.java @@ -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 ""; diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestMeta.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestMeta.java index 367763f2b..030596960 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestMeta.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestMeta.java @@ -7,6 +7,8 @@ import java.util.List; public record ToolManifestMeta( String version, long timeoutMillis, + boolean retryEnabled, + int retryMaxAttempts, boolean enabled, List exampleQueries, List tags, diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestService.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestService.java index 50ad05858..1efb9ef17 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestService.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/manifest/ToolManifestService.java @@ -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> 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 tools) { try { return objectMapper.writeValueAsString(Map.of("bundleId", bundleId, "tools", tools)); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapper.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapper.java index e0198b009..548e543df 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapper.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapper.java @@ -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 target, String key, Object value) { if (value instanceof String text && !text.isBlank()) { target.put(key, text); diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSender.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSender.java index 5fd211a31..29b85e300 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSender.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSender.java @@ -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 검색 *

diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java index 4dd6fa8d6..d54246839 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/lib/util/ToolScaffolder.java @@ -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"); } diff --git a/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/dto/ToolMetadata.java b/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/dto/ToolMetadata.java index 29d536db2..6eafeaa71 100644 --- a/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/dto/ToolMetadata.java +++ b/dat-was-lib/src/main/java/io/shinhanlife/dat/mcc/dto/ToolMetadata.java @@ -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; diff --git a/dat-was-lib/src/main/resources/static/tool-test-console.html b/dat-was-lib/src/main/resources/static/tool-test-console.html index 31aa9f5ba..f7cc09597 100644 --- a/dat-was-lib/src/main/resources/static/tool-test-console.html +++ b/dat-was-lib/src/main/resources/static/tool-test-console.html @@ -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', diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/annotation/GrowToolHintTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/annotation/GrowToolHintTest.java new file mode 100644 index 000000000..6a15f32d5 --- /dev/null +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/annotation/GrowToolHintTest.java @@ -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()); + } +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/manifest/ToolManifestServiceTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/manifest/ToolManifestServiceTest.java index 685943d1a..9282e0dff 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/manifest/ToolManifestServiceTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/manifest/ToolManifestServiceTest.java @@ -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) diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapperTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapperTest.java new file mode 100644 index 000000000..7bfcbfa05 --- /dev/null +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolMetadataMcpMapperTest.java @@ -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 meta = ToolMetadataMcpMapper.meta(metadata); + + assertEquals(12000L, meta.get("timeoutMillis")); + assertEquals(false, meta.get("retryEnabled")); + assertEquals(5, meta.get("retryMaxAttempts")); + } +} diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSenderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSenderTest.java index ac66377e8..cc02d3114 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSenderTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/mcp/ToolRegistryHeartbeatSenderTest.java @@ -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() { } } diff --git a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java index 5bdb32b18..6e8a5abf5 100644 --- a/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java +++ b/dat-was-lib/src/test/java/io/shinhanlife/dat/lib/util/ToolScaffolderTest.java @@ -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);