12 Commits

Author SHA1 Message Date
juheelee
9d16b8ef3a feat: markdown 추가 2026-07-30 17:59:27 +09:00
juheelee
39f68ccf46 feat: markdown 추가 2026-07-30 17:59:06 +09:00
jade
b5ff930556 docs: refresh project README
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s
2026-07-30 10:16:25 +09:00
Gitea CI
2c4c447a8f feat: OpenRouter 실존 무료 모델 교체 및 Gemini API 라우팅 전환
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m9s
- chat.html: 드롭다운을 검증된 무료 모델로 교체
  * Ling 3.0 Flash (inclusionai/ling-3.0-flash:free) 기본값으로 설정
  * GPT-OSS 20B, Gemma 4 31B, NVIDIA Nemotron Nano, Cohere North Mini 추가
  * 삭제된 모델(qwen-2-7b, llama-3-8b, openchat-7b 등) 제거
- ChatController.java: Gemini 직접 API(비정상 키) 제거
  * gemini-flash-latest 선택 시 OpenRouter Google Gemma 4 31B 로 라우팅
  * 불필요한 70여 라인 직접 HTTP 호출 코드 제거
- E2E 검증 완료: Ling 3.0 Flash 정상 응답 확인
2026-07-30 07:28:56 +09:00
hjgram
68e1bf95fd merge: remote updates into main with tool-core schema updates
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m17s
2026-07-30 06:26:41 +09:00
hjgram
204dd04d5f feat: OCI 배포 듀얼 LLM 스위칭 및 신규 툴 DTO 의존성 주입 병합 2026-07-30 06:19:06 +09:00
jade
5fe8590695 feat: use MCP SDK for tool pod calls
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
2026-07-29 23:03:11 +09:00
0a2464d426 Merge pull request 'feat: 메타 테이블 조회' (#3) from feature/meta-table-check into main
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m21s
Reviewed-on: #3
2026-07-29 17:30:32 +09:00
juheelee
a667431de1 feat: 메타 테이블 조회 2026-07-29 17:27:27 +09:00
mjh
aca0e009bc Merge feature/20260729-mjh: add SOL request detail tool
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m52s
2026-07-29 17:17:37 +09:00
df537fa77c Merge pull request 'DEV-SRTEST' (#2) from DEV-SRTEST into main
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m51s
Reviewed-on: #2
2026-07-29 17:04:40 +09:00
ea1aea81bb feat: add SOL request detail tool 2026-07-29 14:26:38 +09:00
32 changed files with 1530 additions and 259 deletions

623
README.md
View File

@@ -1,154 +1,469 @@
# DAP Backend # AX HUB MCP Tool
Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트 입니다. 신한라이프 업무 시스템과 AI Agent를 연결하는 MCP(Model Context Protocol) Gateway Tool 서버 프로젝트입니다.
--- Agent는 Gateway에서 Tool 목록과 입력 명세를 받고, Gateway는 권한과 정책을 확인한 뒤 Tool 서버로 요청을 전달합니다. 업무 Tool은 `DTO → UseCase → Converter → MCI/EAI Client` 구조로 레거시 시스템을 호출합니다.
## 아키텍처 개요 (Architecture Overview) ## 전체 흐름
DAP Backend는 2개의 주요 애플리케이션으로 분리 운영됩니다: ```text
AI Agent / MCP Client
1. **MCP Gateway (`DapGatewayApplication`)**: 외부 LLM(Claude, GPT 등) 서버의 MCP 통신을 받아, 내부 Tool 서버들로 분배(라우팅)하는 허브 서버이자 관리자 웹(Scaffolder)을 제공하는 통합 서버 (포트: 8081)
2. **MCP Tool (`DapTool*Application`)**: 실제 레거시 시스템(MCI, EAI 등)과 통신하여 비즈니스 로직(결제, 휴가신청 등)을 수행하는 어댑터 서버 (포트: 8082~8085 등 분산 구성 가능)
MCP Gateway (dap-gateway)
--- ├─ Tool 목록·스키마 제공
├─ Tool 권한·승인·가드레일 확인
## 환경 (Environment) ├─ Redis Registry 및 실행 추적
└─ 대상 Tool 서버로 라우팅
| 항목 | 버전 |
|------|------|
| Java | 21 | Tool Server (dap-tool-sms / dap-tool-oth)
| Spring Boot | 4.0.5 | └─ BusinessToolController
| Build Tool | Gradle |
| 주요 기술 스택 | MyBatis, Lombok, MapStruct, P6Spy |
| 데이터베이스 | H2 (in-memory, 로컬 개발용) | UseCase → Converter → MCI/EAI Client → 레거시 시스템
| 세션/캐시 저장소 | Redis | ```
| **장애 격리 / 제어** | **Resilience4j (RateLimiter, CircuitBreaker, Retry)** |
| **메시지 큐** | **Kafka (트래픽 폭주 시 대기열 전환용)** | ## 현재 구조와 목표 구조
--- 현재는 Gateway와 두 개의 Tool 애플리케이션으로 구성됩니다.
## ▶ 실행 방법 (How to Run) ```text
현재: Gateway + SMS Tool Pod + OTH Tool Pod
### 1. Gateway & Tool 서버 실행 (MCP 연동용) 목표: Gateway + 고객 Pod + 영업 Pod + 지급/납입 Pod + 알림 Pod + 인사 Pod + 공통 Pod
- **Gateway 서버 기동:** ```
- `./gradlew :dap-gateway:bootRun`
- **Tool 서버 기동:** `dap-tool-oth`에는 여러 업무 카테고리가 함께 있습니다. AA 협의 후에는 부서·업무 소유권 단위로 Tool 서버, 이미지, Pod, 배포 파이프라인을 분리합니다. 이 목표 구조는 향후 전환 방향이며 현재 구현 완료 상태가 아닙니다.
- `./gradlew :dap-tool-oth:bootRun` (또는 dap-tool-payment 등)
- Tool 서버가 기동되면 자동으로 Gateway(8081)에 자신을 등록(Auto-Registration)합니다. ## Gradle 멀티모듈
- **(선택) 특정 Tool 그룹만 실행하기:**
- 업무 특성에 따라 세분화된 그룹에 속한 Tool만 띄우고 싶다면, 실행 인수에 `--mcp.tool.target=그룹명`을 추가합니다. | 모듈 | 역할 | 실행 포트 |
- **지원되는 그룹명:** |---|---|---:|
- `NOTIFICATION`: 이메일, SMS 발송 | `dap-gateway` | MCP 진입점, Tool Registry, 라우팅, 권한·가드레일, Chat API | 8081 |
- `CLAIM`: 청구 처리, 심사 상태 조회 | `dap-tool-core` | 공통 어노테이션, Controller, JSON Schema, MCI/EAI 지원, 보안·로깅 공통 기능 | 라이브러리 |
- `POLICY`: 증권 발행, 발행 가능 여부 조회 | `dap-tool-sms` | SMS/알림 Tool 서버 | 8082 |
- `HR`: 휴가 등록, 연차 갯수 조회 | `dap-tool-oth` | 공통·업무·샘플·MCI Tool 서버 | 8084 |
- `CONTRACT`: 계약 상태, 계약 상세 조회
- `CUSTOMER`: 고객 등급, 고객 상세 정보 조회 기술 기준은 Java 21, Spring Boot 4, Gradle, Spring AI MCP Server, Redis, MapStruct, MyBatis, Resilience4j입니다.
- `SAMPLE`: 날씨, 환율, 명언 조회 등 외부 연동 샘플
- IntelliJ IDEA: `Run/Debug Configurations`에서 `DapTool*Application``Program arguments``--mcp.tool.target=NOTIFICATION` 입력 ## 환경 (Environment)
| 항목 | 버전 / 기준 |
--- |---|---|
| Java | 21 |
## 🤖 AI Agent 연동 아키텍처 (MCP & Agent Builder) | Spring Boot | 4.0.5 |
| Gradle Wrapper | 8.14.3 |
본 시스템은 **투트랙(Two-Track) AI 연동 아키텍처**를 제공하여 로컬 개발 환경과 프로덕션 환경 모두를 완벽하게 지원합니다. | Spring AI BOM | 2.0.0 |
| Spring AI MCP Server | `spring-ai-starter-mcp-server-webmvc` |
### 1. 로컬 코딩 AI (Antigravity, Cursor, Claude Desktop 등) 연동 | Redis Client | Lettuce 6.6.0.RELEASE |
표준 MCP 통신(Stdio)을 요구하는 로컬 AI 에이전트를 위해 자바 기반의 브릿지 스크립트(`McpBridge.java`)를 내장하고 있습니다. 브릿지가 Stdio 요청을 HTTP로 변환하여 로컬 환경의 Gateway(포트: 8281)로 전달합니다. | Resilience | Resilience4j 2.2.0 |
| MyBatis Spring Boot Starter | 3.0.3 |
- **설정 방법**: IDE의 `mcp_config.json` 설정 파일에 아래와 같이 등록합니다. | MapStruct | 1.5.5.Final |
```json | Lombok | 1.18.32 |
"mcpServers": { | JSON Schema Validator | networknt 1.4.0 |
"dap-gateway": { | OpenAPI UI | springdoc 2.5.0 |
"command": "java", | 컨테이너 실행 | Docker Compose |
"args": ["C:/절대경로/dap-backend-main/McpBridge.java"]
} 프로젝트는 JDK 21을 기준으로 컴파일됩니다. IntelliJ에서는 Project SDK, Gradle JVM, Run Configuration JRE를 모두 JDK 21로 맞춰야 합니다.
} ## 5분 빠른 시작
```
- **특정 카테고리 툴 필터링**: `McpBridge.java` 내부의 URI 파라미터(`?categoryKey=sample` 등)를 수정하여 원하는 도메인의 툴만 선택적으로 AI에게 학습시킬 수 있습니다. Docker와 JDK 21이 준비된 로컬 개발 환경 기준입니다.
### 2. 프로덕션 클라우드 AI (Google Cloud Agent Builder 등) 연동 ```powershell
실제 라이브 서비스에서 동작하는 클라우드 Agent Builder는 REST API 기반의 OpenAPI Spec을 요구합니다. # 1. Redis와 MCI Mock만 먼저 실행
`dap-gateway`는 이미 **Agent Builder 규격의 REST API(`/mcp/api/v1/tools/call`)를 네이티브로 제공**하므로, 별도의 브릿지나 어댑터 없이 Endpoint URL과 Swagger(OpenAPI) 문서만 클라우드 콘솔에 등록하면 즉시 라이브 챗봇/에이전트로 서비스할 수 있습니다. $env:ACTIVE_PROFILE = 'local'
docker compose up -d redis mci-mock
---
# 2. Gateway 실행 (새 PowerShell)
## 비공개 Tool 관리 및 Fallback 연동 (Visibility & Routing) $env:SPRING_PROFILES_ACTIVE = 'local'
$env:OPENROUTER_API_KEY = '<개발용 비밀 저장소의 키>'
저희 시스템은 MSA 보안 및 아키텍처 원칙에 따라 Tool의 **레지스트리 등록 여부(라우팅)**와 **API 노출 여부(가시성)**를 완벽히 분리하여 관리합니다. .\gradlew.bat :dap-gateway:bootRun
1. **`visible = false`**: # 3. OTH Tool 실행 (또 다른 PowerShell)
레지스트리에 정상적으로 등록되어 게이트웨이가 동적으로 라우팅하지만, 클라이언트에게 제공되는 `/tools/list` API 목록에서는 숨겨집니다. $env:SPRING_PROFILES_ACTIVE = 'local'
2. **`register = false`**: $env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
내부 레지스트리(Redis)에 툴 정보를 등록하지 않습니다 (외부 레지스트리를 독자적으로 사용할 경우 등). $env:AXHUB_TOOL_URL = 'http://localhost:8084'
이 경우 게이트웨이는 `application.yml`의 `mcp.gateway.fallback.routes` 설정을 참조하여 **Fallback 정적 라우팅**을 수행하므로 연동이 100% 보장됩니다. .\gradlew.bat :dap-tool-oth:bootRun
```
```java
@McpFunction( Tool 서버가 기동된 뒤 아래 URL로 등록된 Tool 목록을 확인합니다.
name = "secret_tool",
visible = false, // 목록 숨김 여부 (기본값: true) ```text
register = false // 내부 Redis 등록 여부 (기본값: true) http://localhost:8081/mcp/api/v1/tools/list
) ```
```
SMS Tool도 함께 확인하려면 별도 PowerShell에서 아래 명령을 실행합니다.
---
```powershell
## 🛡️ 시스템 안정성 및 네트워크 제어 (Resilience & Network) $env:SPRING_PROFILES_ACTIVE = 'local'
$env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
MSA 및 외부 시스템(MCI) 연동 환경의 안정성을 위해 완벽한 3-Tier 방어 체계를 구축했습니다. $env:AXHUB_TOOL_URL = 'http://localhost:8082'
.\gradlew.bat :dap-tool-sms:bootRun
1. **Gateway 라우팅 방어 (Timeout & Fallback):** ```
- MCP 라우터(`McpRouterController`) 단에 1초 타임아웃을 강제 적용하여 특정 Tool Pod의 응답 지연이 전체 시스템 장애로 이어지는 것을 방지하고 신속하게 정적 Fallback 라우팅으로 전환합니다.
2. **MCI 네트워크 안정화 (HTTP/1.1 Downgrade):** 전체 컨테이너 환경이 필요하면 개별 실행 대신 다음 한 줄을 사용합니다.
- 기존 HTTP/2 사용 시 레거시 시스템 연동 중 간헐적으로 발생하던 `RST_STREAM` 오류를 원천 차단하기 위해, MCI 전용 `HttpEimsSender`에는 고도로 최적화된 **HTTP/1.1 전용 커넥션 풀(Factory)**이 고정 적용되어 네트워크 단절을 방지합니다.
3. **Resilience4j 기반 트래픽 제어:** ```powershell
- **Gateway 계층 (동적 방어):** Tool 등록 시 제출된 SLA 메타데이터를 기반으로 동적 CircuitBreaker 및 RateLimiter를 가동하며, 한계치 초과 시 Kafka 큐로 비동기 전환합니다. $env:ACTIVE_PROFILE = 'local'
- **Tool 계층 (정적 방어):** 레거시 커넥터 내부에 `@CircuitBreaker`, `@RateLimiter` 어노테이션 기반의 장애 전파 차단 로직이 2차적으로 가동됩니다. docker compose up -d --build
```
--- ## Tool 등록과 실행
## 모듈(Pod) 및 Tool 코드 자동 생성 (Scaffolders) ### 등록과 목록 제공
새로운 도메인의 기능을 추가할 때 발생하는 반복적인 설정(보일러플레이트, 설정 파일 복사 등)을 1초 만에 자동화하기 위해 **DAP Developer Portal (Web UI)** 및 **CLI 스캐폴더 2종**을 제공합니다. 1. Tool 서버 기동 시 `ToolRegistryHeartbeatSender``@McpTool`, `@McpFunction`을 스캔합니다.
2. Tool 이름, 설명, 입력 JSON Schema, `categoryKey`, 연동 방식, 실행 URL을 메타데이터로 생성합니다.
### 1. DAP Developer Portal (Web UI) - 가장 추천하는 방식! 3. Gateway Redis Registry에 등록·Heartbeat 정보를 전송합니다.
이제 더 이상 터미널에서 명령어를 칠 필요가 없습니다. Gateway 모듈에 내장된 웹 화면에서 빈칸만 채우면 신한라이프 패키지 개발 가이드에 맞춘 코드가 마법처럼 찍혀 나옵니다. 4. Agent와 관리 화면은 Gateway에서 Tool 목록과 명세를 조회합니다.
1. **접속 방법**: Gateway 서버 기동 후 브라우저에서 `http://localhost:8081/admin/scaffold.html` 접속 ### 실행
2. **Pod (모듈) 생성 탭**: 모듈명(예: hr)과 포트만 입력하면 독립적인 Spring Boot 모듈이 디렉토리부터 빌드 스크립트까지 완벽히 생성됩니다.
3. **Tool (기능) 생성 탭**: 생성된 모듈에 새로운 툴 코드를 자동으로 주입합니다. 1. Agent가 Gateway에 Tool 이름과 입력값을 보냅니다.
- **MCI 연동 기반 툴 생성**: 4자리 시스템 코드(예: `nclg`)를 기반으로 알맞은 패키지에 `MciNclgClient`, `Converter`, `_I`, `_O` 파일이 정확하게 생성됩니다. 2. Gateway가 Tool 존재 여부, 허용 Tool, 쓰기 승인, 가드레일을 확인합니다.
- **완벽한 보일러플레이트 자동화**: `UseCaseImpl` 내부에 컴포넌트(`Client`, `Converter`)가 자동으로 의존성 주입되며, Java 15 Text Block을 활용해 들여쓰기(Indentation)까지 완벽히 정렬된 코드를 제공합니다. 3. Gateway가 Tool 서버의 `/mcp/{toolName}`으로 요청을 전달합니다.
4. `BusinessToolController`가 Tool 메서드를 찾아 DTO로 변환하고 JSON Schema를 검증합니다.
### 2. CLI 스캐폴더 (기존 터미널 방식) 5. UseCase가 업무 흐름을 수행합니다.
웹 화면을 사용할 수 없는 환경이거나 터미널이 익숙한 경우, 아래 명령어를 통해 CLI 마법사를 사용할 수 있습니다. 6. Converter가 업무 DTO를 인터페이스 ID 기반 MCI 요청 DTO로 변환합니다.
7. MCI/EAI Client가 레거시를 호출하고 결과를 Tool 응답으로 반환합니다.
### 1⃣ 새로운 Pod(모듈) 전체를 생성할 때: `PodScaffolder`
새로운 도메인(예: 결제, HR)을 위한 완전히 독립적인 Spring Boot 모듈을 생성합니다. 폴더 구조, 빌드 스크립트, 각종 프로퍼티 및 도커 설정까지 완벽하게 세팅됩니다. ## 주요 URL
```bash 로컬에서 Gateway를 직접 실행할 때의 기준입니다. Docker Compose를 사용하면 Gateway 호스트 포트는 `8281`입니다.
# 사용법: javac로 컴파일 후 실행
javac -encoding UTF-8 dap-common/src/main/java/io/shinhanlife/dap/common/util/PodScaffolder.java | 용도 | 메서드 | URL |
java -cp dap-common/src/main/java io.shinhanlife.dap.lib.util.PodScaffolder [모듈명] [포트번호] |---|---|---|
| Tool 목록 | `GET` | `http://localhost:8081/mcp/api/v1/tools/list` |
# 실행 예시 (dap-tool-hr 모듈을 8086 포트로 생성) | Tool 실행 | `POST` | `http://localhost:8081/mcp/api/v1/tools/call` |
java -cp dap-common/src/main/java io.shinhanlife.dap.lib.util.PodScaffolder hr 8086 | Tool Markdown 문서 | `GET` | `http://localhost:8081/mcp/api/v1/tools/docs/markdown` |
``` | 카테고리별 SSE MCP 연결 | `GET` | `http://localhost:8081/mcp/sse/{categoryKey}` |
| 카테고리별 MCP 호출 채널 | `POST` | `http://localhost:8081/mcp/custom/{categoryKey}` |
### 2⃣ 생성된 모듈에 새로운 툴(Function)을 추가할 때: `ToolScaffolder` | Chat 스트리밍 API | `POST` | `http://localhost:8081/api/chat` |
어노테이션(`@McpTool`, `@McpFunction`)이 완벽히 달린 Service와 입출력 DTO 코드를 지정된 모듈 패키지 룰에 맞춰 자동 생성합니다. | Scaffold API | `POST` | `http://localhost:8081/api/v1/scaffold/pod` 또는 `/tool` |
```bash `/mcp/sse/{categoryKey}`는 SSE 연결을 여는 전송 경로이고, `/mcp/custom/{categoryKey}`는 같은 카테고리의 MCP 요청을 처리하는 호출 경로입니다. 두 URL은 역할이 다릅니다.
# 사용법: javac로 컴파일 후 실행
javac -encoding UTF-8 dap-common/src/main/java/io/shinhanlife/dap/common/util/ToolScaffolder.java ## Tool 개발 표준
java -cp dap-common/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder [Tool이름] [인터페이스ID] "[기능설명]" "[그룹명]" "[통신방식]" "[모듈명]"
| 구성 요소 | 책임 |
# 실행 예시 (payment 모듈에 결제 승인 기능 추가) |---|---|
java -cp dap-common/src/main/java io.shinhanlife.dap.lib.util.ToolScaffolder PaymentApproval PAY_001 "결제 승인 처리 기능" "COMMON" "HTTP" "dap-tool-payment" | `XxxRequest`, `XxxResponse` | Agent/Tool 관점의 입력·응답 DTO |
``` | `XxxUseCase` | Tool 계약과 MCP 메타데이터 선언 |
| `XxxUseCaseImpl` | 업무 흐름 조합과 Client 호출 |
--- | `XxxConverter` | 업무 DTO와 레거시 인터페이스 DTO 사이 변환 |
| `MciXxxClient` | Glow/MCI 또는 EAI 통신 호출 |
| `INTERFACE_ID_I`, `INTERFACE_ID_O` | 인터페이스 ID 기준 MCI 요청·응답 DTO |
### Tool 선언
Tool 그룹에는 `@McpTool`, Agent가 호출하는 함수에는 `@McpFunction`을 사용합니다.
```java
@McpTool(routingType = "MCI", categoryKey = "claim")
public interface ClaimInquiryUseCase {
@McpFunction(
name = "claim_inquiry",
displayName = "보험금 청구 조회",
description = "청구 번호로 보험금 청구 상태를 조회합니다.",
mappingId = "CLM00000001"
)
ClaimInquiryResponse inquire(ClaimInquiryRequest request);
}
```
`categoryKey`는 Tool의 업무 그룹입니다. Gateway의 목록 필터링, Agent 권한, 동적 MCP 서버 구분에 사용하므로 합의된 업무 키를 사용합니다.
- `MCI`: 사내 MCI 인터페이스 호출
- `EAI`: EAI 연동
- `DIRECT`: 외부 HTTP 또는 내부 직접 연동
### 변환 원칙
UseCase 구현체는 Tool 요청을 레거시 요청과 섞어 쓰지 않습니다. Converter에서 변환한 뒤 Client에 전달합니다.
```java
@Override
public ClaimInquiryResponse inquire(ClaimInquiryRequest request) {
CLM00000001_I mciRequest = converter.toMciRequest(request);
CLM00000001_O mciResponse = mciClmClient.callClm00000001(mciRequest);
return converter.toResponse(mciResponse);
}
```
MCI 입출력 객체는 업무 이름이 아니라 인터페이스 ID를 기준으로 둡니다.
```text
CLCNNB00001_I : CLCNNB00001 요청 DTO
CLCNNB00001_O : CLCNNB00001 응답 DTO
```
예를 들어 `Onnba3011Request``CLCNNB00001_I`는 같은 업무 데이터를 담을 수 있지만 같은 객체가 아닙니다. 둘 사이의 변환 책임은 `Onnba3011Converter`에 둡니다.
### 새 Tool 추가 체크리스트
새 업무 Tool을 추가할 때는 아래 순서로 확인합니다.
- [ ] 소속 모듈과 `categoryKey`를 업무 소유 조직 기준으로 결정한다.
- [ ] Agent 입력·응답 DTO인 `XxxRequest`, `XxxResponse`를 만든다.
- [ ] `XxxUseCase``@McpTool`을 선언하고, 호출 메서드에 `@McpFunction`의 이름·설명·연동 ID를 선언한다.
- [ ] `XxxUseCaseImpl`에서 업무 흐름만 조합한다.
- [ ] `XxxConverter`에 업무 DTO ↔ 인터페이스 ID DTO 변환을 둔다.
- [ ] `MciXxxClient``INTERFACE_ID_I`, `INTERFACE_ID_O`를 인터페이스 ID 기준으로 만든다.
- [ ] DTO에 Bean Validation을 선언하고, 중첩 DTO가 있으면 입력 Schema와 검증 대상에 포함되는지 확인한다.
- [ ] 조회·변경 작업 특성에 따라 `readOnlyHint`, `requiresApproval`, `idempotentHint`를 설정한다.
- [ ] 단위 테스트를 작성하고 `:dap-tool-core:test` 또는 대상 모듈 테스트를 실행한다.
- [ ] Tool 서버 기동 후 `/mcp/api/v1/tools/list`에서 이름, 설명, category, JSON Schema가 맞는지 확인한다.
- [ ] 요청·응답 로그에 개인정보나 인증값이 남지 않는지 확인한다.
### 등록·노출 제어
| 속성 | 의미 |
|---|---|
| `register` | Redis Registry와 Gateway 카탈로그에 등록할지 여부 |
| `visible` | Agent/클라이언트 목록에 표시할지 여부 |
| `requiresApproval` | 쓰기·고위험 작업의 승인 요구 여부 |
| `readOnlyHint`, `destructiveHint`, `idempotentHint` | Agent 호출 특성 힌트 |
`register = false`인 Tool은 자동 카탈로그 등록 대상이 아닙니다. 별도 실행 목적이 있는 경우에만 사용하고, 필요한 fallback 경로를 운영 설정으로 확인합니다.
## 빌드·테스트·로컬 실행
### 사전 조건
- JDK 21
- Gradle Wrapper 사용 권장
- 로컬 Redis 또는 Docker Compose 환경
- 필요 시 MCI Mock 또는 사내 MCI/EAI 접근 환경
### 전체 빌드와 대표 검증
```powershell
.\gradlew.bat clean build
.\gradlew.bat :dap-tool-core:test
.\gradlew.bat :dap-tool-core:compileJava
```
### 애플리케이션 실행
각 애플리케이션은 별도 PowerShell에서 실행합니다.
```powershell
# Gateway
.\gradlew.bat :dap-gateway:bootRun
# SMS Tool
.\gradlew.bat :dap-tool-sms:bootRun
# 기타 업무 Tool
.\gradlew.bat :dap-tool-oth:bootRun
```
기본 프로필은 `local`입니다. 개발 서버 설정이 필요하면 실행 환경에 프로필을 지정합니다.
```powershell
$env:SPRING_PROFILES_ACTIVE = 'dev'
.\gradlew.bat :dap-tool-oth:bootRun
```
## Docker Compose 실행
Docker Compose는 Redis, Gateway, MCI Mock, SMS Tool, OTH Tool을 함께 기동합니다.
```powershell
$env:ACTIVE_PROFILE = 'local'
docker compose up -d --build
docker compose ps
```
| 서비스 | 컨테이너 포트 | 호스트 포트 |
|---|---:|---:|
| Redis | 6379 | 6379 |
| Gateway | 8081 | 8281 |
| MCI Mock | 8080 | 8089 |
| SMS Tool | 8082 | 8282 |
| OTH Tool | 8084 | 8284 |
컨테이너 내부와 PC 브라우저의 접속 주소는 다릅니다.
```text
컨테이너 내부: http://gateway:8081, http://tool-oth:8084
PC 브라우저: http://localhost:8281, http://localhost:8284
```
## 환경 설정
### 프로필
기본 프로필은 `local`입니다. Tool 서버와 Gateway 모두 `local`, `dev` 프로필 파일을 사용합니다.
| 프로필 | 목적 | 주요 설정 파일 |
|---|---|---|
| `local` | PC 개발·MCI Mock·H2 메모리 DB 기반 실행 | `application-local.yml` |
| `dev` | 개발 서버 배포 실행 | `application-dev.yml` |
프로필은 환경 변수로 지정합니다.
```powershell
# 로컬 개발
$env:SPRING_PROFILES_ACTIVE = 'local'
# 개발 서버 설정으로 실행
$env:SPRING_PROFILES_ACTIVE = 'dev'
```
### 환경별 연결 주소 비교
| 항목 | `local` 프로세스 실행 | Docker Compose | `dev` 프로필 |
|---|---|---|---|
| Gateway 접근 주소 | `http://localhost:8081` | 컨테이너 내부 `http://gateway:8081` | 배포 환경 Gateway URL |
| SMS Tool 주소 | `http://localhost:8082` | `http://tool-sms:8082` | `PORT` 기본 8082 |
| OTH Tool 주소 | `http://localhost:8084` | `http://tool-oth:8084` | `PORT` 기본 8084 |
| Redis 주소 | 로컬 Redis 또는 `localhost:6379` | `redis:6379` | 운영/개발 Redis 설정 |
| MCI/EAI 대상 | MCI Mock 또는 로컬 설정 | `mci-mock:8080` | 개발망 연동 설정 |
| 브라우저 Gateway 접속 | `http://localhost:8081` | `http://localhost:8281` | 운영·개발 도메인 |
`local`에서 프로세스를 직접 실행하면 Tool의 `AXHUB_GATEWAY_URL``localhost`를 사용합니다. Docker에서는 각 컨테이너가 서로 다른 네트워크 공간에 있으므로 반드시 Compose 서비스 이름을 사용합니다.
### Gateway 환경 변수
| 변수 | 적용 대상 | 설명 | 로컬 기본값/예시 |
|---|---|---|---|
| `SPRING_PROFILES_ACTIVE` | Gateway, 모든 Tool | 활성 Spring 프로필 | `local` |
| `OPENROUTER_API_KEY` | Gateway | Chat/LLM 호출 API 키 | 운영·개발 환경의 비밀 저장소에서 주입 |
| `SPRING_DATA_REDIS_HOST` | Gateway, 모든 Tool | Redis 호스트 | Docker: `redis` |
| `SPRING_DATA_REDIS_PORT` | Gateway, 모든 Tool | Redis 포트 | `6379` |
| `MCP_GATEWAY_FALLBACK_DEFAULT_URL` | Gateway | Registry에 없는 Tool의 기본 fallback URL | Docker: `http://tool-oth:8084` |
| `MCP_GATEWAY_FALLBACK_ROUTES_SMS` | Gateway | SMS 계열 fallback URL | Docker: `http://tool-sms:8082` |
| `ACTIVE_PROFILE` | Docker Compose | Compose가 `SPRING_PROFILES_ACTIVE`에 전달할 프로필 | `local` |
`OPENROUTER_API_KEY` 같은 인증값은 `application.yml`, README, Git 커밋에 직접 넣지 않습니다. 개발·운영 환경의 Secret, CI/CD 변수 또는 안전한 환경 변수로 주입합니다.
### Tool 서버 환경 변수
| 변수 | 적용 대상 | 설명 | 로컬 기본값/예시 |
|---|---|---|---|
| `PORT` | `dap-tool-sms`, `dap-tool-oth` | 개발 프로필에서 Tool 서버 포트 변경 | SMS `8082`, OTH `8084` |
| `AXHUB_GATEWAY_URL` | 모든 Tool | Tool 등록·Heartbeat 대상 Gateway 주소 | 로컬 `http://localhost:8081`, Docker `http://gateway:8081` |
| `AXHUB_TOOL_URL` | 모든 Tool | Gateway가 해당 Tool Pod를 호출할 주소 | 로컬 `http://localhost:{server.port}` |
| `GLOW_COMMUNICATION_MCI_HOMT` | Docker Tool 컨테이너 | MCI 대상 호스트 | 로컬 Compose는 `mci-mock` |
| `GLOW_COMMUNICATION_MCI_PORT` | Docker Tool 컨테이너 | MCI 대상 포트 | 로컬 Compose는 `8080` |
| `GLOW_COMMUNICATION_EAI_HOMT` | Docker Tool 컨테이너 | EAI 대상 호스트 | 로컬 Compose는 `mci-mock` |
| `GLOW_COMMUNICATION_EAI_PORT` | Docker Tool 컨테이너 | EAI 대상 포트 | 로컬 Compose는 `8080` |
`AXHUB_TOOL_URL`은 반드시 Gateway가 실제로 접근 가능한 주소여야 합니다. PC에서 각각 실행할 때는 `localhost`를 사용하고, Docker 컨테이너 안에서는 `tool-sms`, `tool-oth` 같은 Compose 서비스 이름을 사용합니다.
### 로컬 실행용 권장 설정
아래는 키 값 없이 로컬 프로세스를 실행하는 예시입니다. Redis를 Docker로 먼저 기동하거나 전체 Docker Compose를 사용합니다.
```powershell
# 선택 1: Redis만 기동
$env:ACTIVE_PROFILE = 'local'
docker compose up -d redis mci-mock
# 선택 2: 각 프로세스를 로컬에서 기동
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:OPENROUTER_API_KEY = '<개인 또는 개발용 비밀 저장소의 키>'
.\gradlew.bat :dap-gateway:bootRun
```
다른 PowerShell에서 Tool을 실행합니다.
```powershell
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
$env:AXHUB_TOOL_URL = 'http://localhost:8084'
.\gradlew.bat :dap-tool-oth:bootRun
```
### 권한 도메인 설정
`mcp.security.tenant-domains`는 테넌트 또는 호출 주체가 접근할 수 있는 `categoryKey`를 정의합니다. 운영 환경에서는 `ALL`을 무분별하게 사용하지 않고, Agent·조직별 허용 도메인을 최소 권한으로 설정합니다.
```yaml
mcp:
security:
tenant-domains:
claims-agent: claim, customer
notification-agent: notification
```
## 공통 기능과 운영 기준
### Redis
Redis는 Tool Registry의 등록 상태와 Heartbeat, Gateway 실행 추적 정보에 사용됩니다. 현재 Redis는 PII 원문 보관소가 아닙니다.
### 권한과 승인
Gateway는 Agent가 전달한 허용 Tool 목록, 서버 정책, 신뢰된 Claim 여부, 쓰기 승인 여부를 확인합니다. Tool마다 권한 로직을 중복 구현하지 말고 Gateway 공통 정책과 `@McpFunction` 메타데이터를 사용합니다.
### Trace ID와 Request ID
```text
trace-id : 사용자 요청 전체에서 유지되는 상관관계 ID
request-id : Gateway → Tool, Tool → MCI 등 HTTP 호출마다 새로 생성되는 ID
```
Gateway와 Tool에는 관련 헤더 및 MDC 기반 로그 처리가 있습니다. 신규 HTTP Client도 공통 전파 정책을 따르며, Tool별로 임의의 헤더 이름을 추가하지 않습니다.
### 로그와 개인정보
Gateway에는 민감 키와 일부 형식을 마스킹하는 공통 기능이 있습니다. 마스킹은 원문을 Agent에서 분리하는 PII 토큰화와는 다릅니다.
- 요청·응답 전문을 로그에 남길 때는 반드시 마스킹합니다.
- 운영 로그에 주민번호, 계좌번호, 전화번호, 이메일, 인증값을 남기지 않습니다.
- Tool 서버의 신규 로그도 같은 마스킹 정책을 적용합니다.
- 민감정보 복원 필요 여부는 Tool 개발자가 임의로 결정하지 않고 보안·AA 정책을 따릅니다.
## AA 협의 기반 향후 전환 과제
아래 항목은 현재 구현 완료 기능이 아니라 회의에서 합의한 목표 구조입니다.
### 부서별 Tool Pod와 저장소 경계
현재 `dap-tool-oth`에 함께 있는 업무 Tool을 부서·업무 소유권 단위로 분리합니다. 각 Pod는 독립 이미지, 독립 배포, 독립 장애 범위를 갖도록 구성합니다. 실제 분리는 AA가 확정한 Tool 소유 부서와 운영 책임 매핑을 기준으로 수행합니다.
### Agent별 Tool 노출 수 제한
Agent에게 모든 Tool을 한 번에 제공하지 않고, 업무 도메인과 권한에 따라 약 10~20개 수준의 Tool 그룹을 제공합니다. `categoryKey`는 이를 위한 기초 메타데이터이며, 향후 Agent-Tool Group 정책으로 확장합니다.
### PII 토큰화
```text
원문 개인정보
→ PII Gateway가 Redis에 짧은 TTL로 보관
→ Agent에는 PII 토큰 또는 안전한 식별자만 전달
→ 인가된 Tool이 MCI 호출 직전에 필요한 항목만 복원
```
이 전환 전까지는 현행 마스킹 기능을 PII 분리 구현으로 오해하지 않아야 합니다.
### MCP SDK와 Glow Framework
Gateway에는 Spring AI MCP Server 의존성이 포함되어 있습니다. MCP SDK/Glow 표준 적용 시에는 업무 Tool을 재작성하지 않고, 기존 `@McpTool`·`@McpFunction`과 UseCase를 표준 MCP Tool 명세·호출 콜백으로 연결하는 Adapter 계층을 공통 Core에 추가합니다.
```text
MCP SDK 표준 tools/list, tools/call
공통 Adapter
기존 UseCase → Converter → MCI Client
```
따라서 업무 DTO, Converter, MCI Client의 책임은 유지됩니다.
## 참고 소스
| 주제 | 대표 위치 |
|---|---|
| Gateway Tool API | `dap-gateway/.../presentation/McpRouterController.java` |
| 동적 MCP SSE/호출 경로 | `dap-gateway/.../sync/DynamicMcpController.java` |
| Tool 실행 Controller | `dap-tool-core/.../presentation/BusinessToolController.java` |
| Tool 자동 등록 | `dap-tool-core/.../usecase/ToolRegistryHeartbeatSender.java` |
| Tool 어노테이션 | `dap-tool-core/.../annotation/McpTool.java`, `McpFunction.java` |
| Tool 예시 | `dap-tool-oth/.../biz/oth`, `biz/sol`, `biz/smp` |
| SMS Tool 예시 | `dap-tool-sms/.../biz/sms` |
| Docker 환경 | `docker-compose.yml` |
---
문서에 없는 업무·보안·배포 기준은 임의로 추가하지 말고 AA 및 플랫폼 운영 기준과 먼저 합의합니다.

View File

@@ -11,6 +11,7 @@ dependencies {
// Spring AI MCP Server // Spring AI MCP Server
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc' implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springframework.ai:spring-ai-starter-model-openai' implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-openai:2.0.0'
// MyBatis & DB // MyBatis & DB
implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

View File

@@ -26,6 +26,7 @@ import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@@ -60,21 +61,40 @@ public class ChatController {
Map<String, Object> resultMap = (Map<String, Object>) toolsResponse.getResult(); Map<String, Object> resultMap = (Map<String, Object>) toolsResponse.getResult();
if (resultMap.containsKey("tools")) { if (resultMap.containsKey("tools")) {
List<ToolMetadata> allTools = (List<ToolMetadata>) resultMap.get("tools"); List<ToolMetadata> allTools = (List<ToolMetadata>) resultMap.get("tools");
Set<String> addedToolNames = new HashSet<>();
for (ToolMetadata meta : allTools) { for (ToolMetadata meta : allTools) {
if (Boolean.TRUE.equals(meta.getVisible())) { if (Boolean.TRUE.equals(meta.getVisible()) && meta.getName() != null) {
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper, effectiveTenantId)); String cleanName = meta.getName().replaceAll("[^a-zA-Z0-9_-]", "_");
if (!addedToolNames.contains(cleanName)) {
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper, effectiveTenantId));
addedToolNames.add(cleanName);
}
} }
} }
} }
} }
ChatClient chatClient = chatClientBuilder String selectedModel = request.getOrDefault("model", "gemini-flash-latest").trim();
if (selectedModel.isEmpty()) {
selectedModel = "gemini-flash-latest";
}
// 1. gemini-flash-latest 선택 시 OpenRouter의 Google Gemma 4 모델로 라우팅
if (selectedModel.equals("gemini-flash-latest")) {
selectedModel = "google/gemma-4-31b-it:free";
log.info("[Real AI Chat] Gemini 직접 API → OpenRouter Google Gemma 4 31B 로 라우팅 전환");
}
// 2. OpenRouter 무료 모델 처리 (Spring AI 빌드된 ChatClient 및 MCP 툴 호출 완벽 지원!)
ChatClient activeChatClient = chatClientBuilder
.defaultSystem("You are AX HUB Assistant, a highly capable enterprise AI agent. You must use the provided tools to answer user questions when necessary. Always answer politely in Korean.") .defaultSystem("You are AX HUB Assistant, a highly capable enterprise AI agent. You must use the provided tools to answer user questions when necessary. Always answer politely in Korean.")
.build(); .build();
Flux<String> responseStream = chatClient.prompt() Flux<String> responseStream = activeChatClient.prompt()
.user(message) .user(message)
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools() .tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools()
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(selectedModel))
.stream() .stream()
.content(); .content();
@@ -97,7 +117,7 @@ public class ChatController {
} else if (errorMsg != null && errorMsg.contains("503")) { } else if (errorMsg != null && errorMsg.contains("503")) {
emitter.send("\n\n⚠ **AI 모델 서버 혼잡 (503)**: 현재 AI 모델을 제공하는 서버에 일시적으로 접속자가 많아 지연이 발생하고 있습니다. 잠시 후 다시 시도해 주세요."); emitter.send("\n\n⚠ **AI 모델 서버 혼잡 (503)**: 현재 AI 모델을 제공하는 서버에 일시적으로 접속자가 많아 지연이 발생하고 있습니다. 잠시 후 다시 시도해 주세요.");
} else if (errorMsg != null && errorMsg.contains("429")) { } else if (errorMsg != null && errorMsg.contains("429")) {
emitter.send("\n\n⚠ **API 사용량 초과 (429)**: 현재 사용 중인 Gemini API(무료 티어)의 일일 또는 분당 요청 한도를 초과했습니다. 잠시 후 다시 시도하시거나 API 플랜을 확인해 주세요."); emitter.send("\n\n⚠ **API 사용량 초과 (429)**: AI 모델 API(무료 티어 또는 오픈라우터)의 요청 한도를 초과했습니다. 잠시 후 다시 시도하시거나 API 키의 유효 한도를 확인해 주세요.");
} else { } else {
emitter.send("\n[에러 발생: " + errorMsg + "]"); emitter.send("\n[에러 발생: " + errorMsg + "]");
} }

View File

@@ -261,7 +261,6 @@ public class ExecuteService {
if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) { if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) {
targetUrl = metadata.getPodUrl(); targetUrl = metadata.getPodUrl();
} }
String executeApiUrl = targetUrl + "/mcp/" + metadata.getName();
Map<String, String> headers = new java.util.HashMap<>(); Map<String, String> headers = new java.util.HashMap<>();
headers.put("trace-id", context.requestId()); headers.put("trace-id", context.requestId());
@@ -279,16 +278,16 @@ public class ExecuteService {
JsonNode data = null; JsonNode data = null;
try { try {
data = toolInvoker.invoke(pagePayload, executeApiUrl, headers); data = toolInvoker.invoke(metadata.getName(), pagePayload, targetUrl, headers);
} catch (org.springframework.web.client.RestClientResponseException e) { } catch (org.springframework.web.client.RestClientResponseException e) {
// HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환 // HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString()); throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString());
} catch (Exception e) { } catch (Exception e) {
if (executeApiUrl.contains("http://tool-")) { if (targetUrl.contains("http://tool-")) {
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost"); String fallbackUrl = targetUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl); log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
try { try {
data = toolInvoker.invoke(pagePayload, fallbackUrl, headers); data = toolInvoker.invoke(metadata.getName(), pagePayload, fallbackUrl, headers);
} catch (Exception ex) { } catch (Exception ex) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage()); throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
} }
@@ -385,4 +384,4 @@ public class ExecuteService {
public void shutdown() { public void shutdown() {
executor.shutdownNow(); executor.shutdownNow();
} }
} }

View File

@@ -1,69 +1,81 @@
package io.shinhanlife.dap.mcg.transport; package io.shinhanlife.dap.mcg.transport;
/**
* @package io.shinhanlife.dap.mcg.transport
* @className HttpToolInvoker
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import com.fasterxml.jackson.databind.node.TextNode;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.Map; import java.util.Map;
import java.util.UUID; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/** MCP SDK client used for Gateway-to-Tool-Pod calls over Streamable HTTP. */
@Slf4j @Slf4j
@Component @Component
public class HttpToolInvoker implements ToolInvoker { public class HttpToolInvoker implements ToolInvoker {
private final RestClient restClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public HttpToolInvoker(ObjectMapper objectMapper) { public HttpToolInvoker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.restClient = RestClient.create();
} }
@Override @Override
public JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers) { public JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers) {
try { String endpoint = podUrl.replaceAll("/+$", "") + "/mcp";
RestClient.RequestBodySpec requestSpec = restClient.post() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
.uri(targetUrl) if (headers != null) {
.contentType(MediaType.APPLICATION_JSON); headers.forEach(requestBuilder::header);
}
if (headers != null) {
headers.forEach(requestSpec::header); HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(endpoint)
.requestBuilder(requestBuilder)
.connectTimeout(Duration.ofSeconds(5))
.build();
try (McpSyncClient client = McpClient.sync(transport)
.clientInfo(new McpSchema.Implementation("dap-gateway", "1.0.0"))
.requestTimeout(Duration.ofSeconds(30))
.build()) {
client.initialize();
McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder()
.name(toolName)
.arguments(arguments)
.build());
if (Boolean.TRUE.equals(result.isError())) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool Pod MCP error: " + textContent(result));
} }
return result.structuredContent() != null
Object httpResult = requestSpec.body(payload) ? objectMapper.valueToTree(result.structuredContent())
.retrieve() : textContentAsJson(result);
.body(Object.class); } catch (ToolExecutionException error) {
throw error;
return extractData(objectMapper.valueToTree(httpResult)); } catch (Exception error) {
} catch (Exception e) { throw new ToolExecutionException(FailureType.SERVER_ERROR,
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 호출 실패: " + e.getMessage(), e); "Tool Pod MCP call failed: " + error.getMessage(), error);
} }
} }
private JsonNode extractData(JsonNode root) { private JsonNode textContentAsJson(McpSchema.CallToolResult result) {
if (!root.path("success").asBoolean(true)) { String text = textContent(result);
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText()); try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
return TextNode.valueOf(text);
} }
return root.has("data") ? root.get("data") : root; }
private String textContent(McpSchema.CallToolResult result) {
return result.content().stream()
.filter(McpSchema.TextContent.class::isInstance)
.map(McpSchema.TextContent.class::cast)
.map(McpSchema.TextContent::text)
.findFirst()
.orElse("");
} }
} }

View File

@@ -22,5 +22,5 @@ import java.util.Map;
* Tool 서버 호출 transport의 최소 공통 인터페이스입니다. * Tool 서버 호출 transport의 최소 공통 인터페이스입니다.
*/ */
public interface ToolInvoker { public interface ToolInvoker {
JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers); JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers);
} }

View File

@@ -10,11 +10,11 @@ spring:
timeout-per-shutdown-phase: 20s timeout-per-shutdown-phase: 20s
ai: ai:
openai: openai:
api-key: AQ.Ab8RN6KFZggsQf8iooY1v_3h3vp2TIjiYB54dV4Yay3vVKEMtg api-key: ${OPENROUTER_API_KEY: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}
base-url: https://generativelanguage.googleapis.com/v1beta/openai/ base-url: https://openrouter.ai/api/v1
chat: chat:
options: options:
model: gemini-flash-latest model: google/gemma-4-31b-it:free
temperature: 0.3 temperature: 0.3
server: server:

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AX HUB - ChatClient</title> <title>AX HUB - ChatClient</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script> <script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<script src="/vendor/marked.umd.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style> <style>
body { font-family: 'Inter', sans-serif; background-color: #0f1115; color: #e2e8f0; } body { font-family: 'Inter', sans-serif; background-color: #0f1115; color: #e2e8f0; }
@@ -25,6 +26,20 @@
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); } 50% { transform: translateY(-3px); }
} }
.markdown-content > :first-child { margin-top: 0; }
.markdown-content > :last-child { margin-bottom: 0; }
.markdown-content p { margin: 0.5rem 0; }
.markdown-content ul, .markdown-content ol { margin: 0.5rem 0; padding-left: 1.5rem; }
.markdown-content ul { list-style: disc; }
.markdown-content ol { list-style: decimal; }
.markdown-content a { color: #6ee7b7; text-decoration: underline; }
.markdown-content code { padding: 0.125rem 0.3rem; border-radius: 0.25rem; background: #0f1115; color: #f1f5f9; }
.markdown-content pre { margin: 0.75rem 0; padding: 0.75rem; overflow-x: auto; border-radius: 0.5rem; background: #0f1115; }
.markdown-content pre code { padding: 0; background: transparent; }
.markdown-content blockquote { margin: 0.5rem 0; padding-left: 0.75rem; border-left: 3px solid #34d399; color: #cbd5e1; }
.markdown-content table { width: 100%; margin: 0.5rem 0; border-collapse: collapse; }
.markdown-content th, .markdown-content td { padding: 0.4rem; border: 1px solid #475569; text-align: left; }
</style> </style>
</head> </head>
<body class="h-screen flex flex-col items-center justify-center p-4"> <body class="h-screen flex flex-col items-center justify-center p-4">
@@ -43,12 +58,23 @@
<p class="text-[11px] text-slate-500">Powered by Mock Engine & MCP Tools</p> <p class="text-[11px] text-slate-500">Powered by Mock Engine & MCP Tools</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-4">
<span class="relative flex h-2.5 w-2.5"> <select id="model-select" class="bg-[#1e2128] text-slate-300 text-xs px-3 py-1.5 rounded-lg border border-white/10 focus:outline-none focus:border-emerald-500/50 cursor-pointer">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span> <option value="inclusionai/ling-3.0-flash:free" selected>Ling 3.0 Flash ⭐ (기본 - OpenRouter)</option>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span> <option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option>
</span> <option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option>
<span class="text-xs text-slate-400">Online</span> <option value="nvidia/nemotron-3-nano-30b-a3b:free">NVIDIA Nemotron Nano 30B (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free">Cohere North Mini (무료 - OpenRouter)</option>
<option value="gemini-flash-latest">Gemini 1.5 Flash (직접 API - Google)</option>
</select>
<div class="flex items-center gap-2">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span class="text-xs text-slate-400">Online</span>
</div>
</div> </div>
</div> </div>
@@ -139,7 +165,7 @@
</div> </div>
<div class="flex flex-col gap-1 max-w-[80%]"> <div class="flex flex-col gap-1 max-w-[80%]">
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span> <span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm whitespace-pre-wrap bot-text"></div> <div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm markdown-content bot-text"></div>
</div> </div>
`; `;
chatBox.appendChild(div); chatBox.appendChild(div);
@@ -173,14 +199,67 @@
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
function removeMarkdownEmphasis(text) { function sanitizeRenderedHtml(html) {
return text.replace(/\*\*/g, ''); const template = document.createElement('template');
template.innerHTML = html;
template.content.querySelectorAll('script, iframe, object, embed, style, link, meta').forEach(element => element.remove());
template.content.querySelectorAll('*').forEach(element => {
[...element.attributes].forEach(attribute => {
const name = attribute.name.toLowerCase();
const value = attribute.value.trim();
if (name.startsWith('on') || name === 'style' ||
(['href', 'src', 'xlink:href'].includes(name) && /^(javascript|data|vbscript):/i.test(value))) {
element.removeAttribute(attribute.name);
}
});
});
return template.innerHTML;
}
function normalizeMarkdown(markdown) {
// 일부 모델은 문단 뒤의 제목 표기(예: "설명입니다.## 제목") 앞 줄바꿈을 생략한다.
// 또한 "###제목", "###📝 제목"처럼 해시 뒤 공백을 생략하는 출력도 제목으로 보정한다.
return markdown
.replace(/\r\n?/g, '\n')
.replace(/([^\n])((?:#{1,6})\s+)/g, '$1\n$2')
.replace(/(^|\n)(#{1,6})([^\s#])/g, '$1$2 $3');
}
function renderMarkdown(container, markdown) {
// 프로젝트에 포함한 표준 GFM 렌더러가 제목, 강조, 표, 목록, 코드, 링크 등을 일괄 처리한다.
const normalizedMarkdown = normalizeMarkdown(markdown);
if (typeof marked !== 'undefined') {
const html = marked.parse(normalizedMarkdown, { breaks: true, gfm: true });
container.innerHTML = sanitizeRenderedHtml(html);
return;
}
// 로컬 라이브러리 로딩에 실패한 경우에는 안전한 일반 텍스트로 표시한다.
container.textContent = normalizedMarkdown;
}
function consumeSseEvents(buffer, onData) {
// Spring SSE는 응답 본문의 줄바꿈을 여러 data: 줄로 전송한다.
// data 줄을 단순 연결하면 Markdown 표의 행 구분이 사라지므로, 이벤트 단위로 복원한다.
let eventEnd;
while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
const event = buffer.substring(0, eventEnd);
buffer = buffer.substring(eventEnd + 2);
const dataLines = event.split('\n')
.filter(line => line.startsWith('data:'))
.map(line => line.substring(5).replace(/^ /, ''));
if (dataLines.length > 0) onData(dataLines.join('\n'));
}
return buffer;
} }
async function sendMessage() { async function sendMessage() {
const text = chatInput.value.trim(); const text = chatInput.value.trim();
if (!text || isLoading) return; if (!text || isLoading) return;
const selectedModel = document.getElementById('model-select').value;
// 1. 유저 메시지 추가 // 1. 유저 메시지 추가
appendUserMessage(text); appendUserMessage(text);
chatInput.value = ''; chatInput.value = '';
@@ -197,7 +276,7 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Agent-Id': 'TESTER-DEV' // 권한 통과를 위한 테스트 Agent ID 'X-Agent-Id': 'TESTER-DEV' // 권한 통과를 위한 테스트 Agent ID
}, },
body: JSON.stringify({ message: text }) body: JSON.stringify({ message: text, model: selectedModel })
}); });
if (!response.ok) throw new Error('Network response was not ok'); if (!response.ok) throw new Error('Network response was not ok');
@@ -205,26 +284,24 @@
// 4. 로딩 숨기고 봇 응답 컨테이너 생성 // 4. 로딩 숨기고 봇 응답 컨테이너 생성
hideLoading(); hideLoading();
const textContainer = appendBotMessageContainer(); const textContainer = appendBotMessageContainer();
let responseText = '';
// 5. 스트림 읽기 // 5. 스트림 읽기
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder("utf-8");
let responseText = ''; let sseBuffer = '';
const appendResponse = (data) => {
responseText += data;
renderMarkdown(textContainer, responseText);
scrollToBottom();
};
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
sseBuffer += decoder.decode(value || new Uint8Array(), { stream: !done }).replace(/\r\n/g, '\n');
sseBuffer = consumeSseEvents(sseBuffer, appendResponse);
if (done) break; if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5);
responseText += data;
textContainer.textContent = removeMarkdownEmphasis(responseText);
scrollToBottom();
}
}
} }
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,44 @@
# License information
## Contribution License Agreement
If you contribute code to this project, you are implicitly allowing your code
to be distributed under the MIT license. You are also implicitly verifying that
all code is your original work. `</legalese>`
## Marked
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Markdown
Copyright © 2004, John Gruber
http://daringfireball.net/
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,8 @@ dependencies {
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1' api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1' api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:1.4.0' api 'com.networknt:json-schema-validator:3.0.0'
api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
api 'org.springframework.kafka:spring-kafka:3.2.0' api 'org.springframework.kafka:spring-kafka:3.2.0'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
} }

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.mcc.mcp;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/** Exposes every Tool Pod through the MCP Streamable HTTP transport. */
@Configuration
public class ToolMcpServerConfiguration {
@Bean
@Primary
public HttpServletStreamableServerTransportProvider toolMcpTransportProvider() {
return HttpServletStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.build();
}
@Bean
public ServletRegistrationBean<HttpServletStreamableServerTransportProvider> toolMcpServlet(
HttpServletStreamableServerTransportProvider transportProvider) {
return new ServletRegistrationBean<>(transportProvider, "/mcp");
}
}

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dap.mcc.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */
@Component
public class ToolPodMcpToolSynchronizer {
private final McpSyncServer mcpServer;
private final ToolRegistryHeartbeatSender heartbeatSender;
private final BusinessToolController businessToolController;
private final ObjectMapper objectMapper;
public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender,
BusinessToolController businessToolController, ObjectMapper objectMapper) {
this.mcpServer = mcpServer;
this.heartbeatSender = heartbeatSender;
this.businessToolController = businessToolController;
this.objectMapper = objectMapper;
}
@EventListener(ApplicationReadyEvent.class)
public void registerLocalTools() {
heartbeatSender.getAllScannedTools().stream()
.filter(tool -> Boolean.TRUE.equals(tool.getVisible()))
.forEach(tool -> mcpServer.addTool(specification(tool)));
}
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema())
.annotations(McpSchema.ToolAnnotations.builder()
.readOnlyHint(Boolean.TRUE.equals(tool.getReadOnlyHint()))
.destructiveHint(Boolean.TRUE.equals(tool.getDestructiveHint()))
.idempotentHint(Boolean.TRUE.equals(tool.getIdempotentHint()))
.openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build())
.build();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), request.arguments())).build();
}
private McpSchema.CallToolResult invoke(String toolName, Map<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool(toolName, null, null, null, arguments);
boolean failed = !response.getStatusCode().is2xxSuccessful();
Object body = response.getBody();
try {
return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body))
.structuredContent(body).isError(failed).build();
} catch (Exception error) {
return McpSchema.CallToolResult.builder().addTextContent(String.valueOf(body)).isError(failed).build();
}
}
private Map<String, Object> emptySchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", Map.of());
schema.put("additionalProperties", false);
return schema;
}
}

View File

@@ -16,10 +16,7 @@ package io.shinhanlife.dap.mcc.presentation;
* </pre> * </pre>
*/ */
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema; import com.networknt.schema.Error;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import io.shinhanlife.dap.lib.annotation.McpFunction; import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool; import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.lib.config.McpProperties; import io.shinhanlife.dap.lib.config.McpProperties;
@@ -58,6 +55,7 @@ public class BusinessToolController {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final McpProperties mcpProperties; private final McpProperties mcpProperties;
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender; private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
// 내부 조회용 로컬 Tool 목록 엔드포인트 // 내부 조회용 로컬 Tool 목록 엔드포인트
@GetMapping("/mcp/api/v1/tools/local") @GetMapping("/mcp/api/v1/tools/local")
@@ -149,17 +147,12 @@ public class BusinessToolController {
if (!Map.class.isAssignableFrom(paramType)) { if (!Map.class.isAssignableFrom(paramType)) {
try { try {
Map<String, Object> autoSchema = JsonSchemaGenerator.generateSchema(paramType); Map<String, Object> autoSchema = JsonSchemaGenerator.generateSchema(paramType);
String fullSchemaJson = objectMapper.writeValueAsString(autoSchema); List<Error> errors = toolArgumentSchemaValidator.validate(autoSchema, arguments);
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(fullSchemaJson);
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors); log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
List<String> errorMessages = new ArrayList<>(); List<String> errorMessages = new ArrayList<>();
for (ValidationMessage vm : errors) { for (Error validationError : errors) {
errorMessages.add(vm.getMessage()); errorMessages.add(validationError.getMessage());
} }
Map<String, Object> errorDetails = new HashMap<>(); Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "422"); errorDetails.put("status", "422");
@@ -229,4 +222,4 @@ public class BusinessToolController {
return ResponseEntity.status(502).body(errorBody); return ResponseEntity.status(502).body(errorBody);
} }
} }
} }

View File

@@ -0,0 +1,28 @@
package io.shinhanlife.dap.mcc.presentation;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import com.networknt.schema.InputFormat;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */
@Component
public class ToolArgumentSchemaValidator {
private final ObjectMapper objectMapper;
public ToolArgumentSchemaValidator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public List<Error> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception {
SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition));
return schema.validate(objectMapper.writeValueAsString(arguments), InputFormat.JSON);
}
}

View File

@@ -5,15 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema; import com.networknt.schema.Error;
import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.InputFormat;
import com.networknt.schema.SpecVersion; import com.networknt.schema.Schema;
import com.networknt.schema.ValidationMessage; import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import io.shinhanlife.dap.lib.annotation.McpParameter; import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation; import io.shinhanlife.dap.lib.annotation.McpValidation;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
/** /**
@@ -65,10 +65,10 @@ class JsonSchemaGeneratorTest {
@Test @Test
void validatorRejectsInvalidNestedValue() throws Exception { void validatorRejectsInvalidNestedValue() throws Exception {
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7) Schema schema = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7)
.getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class))); .getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class)));
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(Map.of( List<Error> errors = schema.validate(objectMapper.writeValueAsString(Map.of(
"child", Map.of("businessDate", "2026-07-28")))); "child", Map.of("businessDate", "2026-07-28"))), InputFormat.JSON);
assertFalse(errors.isEmpty()); assertFalse(errors.isEmpty());
} }

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.mcc.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
class ToolMcpServerConfigurationTest {
private final ToolMcpServerConfiguration configuration = new ToolMcpServerConfiguration();
@Test
void exposesOnlyExactMcpEndpointSoLegacyMcpApiPathsRemainAvailable() {
HttpServletStreamableServerTransportProvider transport = configuration.toolMcpTransportProvider();
ServletRegistrationBean<HttpServletStreamableServerTransportProvider> registration = configuration.toolMcpServlet(transport);
assertEquals("/mcp", registration.getUrlMappings().iterator().next());
assertFalse(registration.getUrlMappings().contains("/mcp/*"));
}
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.dap.mcc.presentation;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ToolArgumentSchemaValidatorTest {
private final ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
@Test
void validatesDraft7SchemaWithTheRuntimeNetworkntVersion() throws Exception {
Map<String, Object> schema = Map.of(
"type", "object",
"properties", Map.of("name", Map.of("type", "string")),
"required", List.of("name"));
assertTrue(validator.validate(schema, Map.of("name", "Hong")).isEmpty());
assertFalse(validator.validate(schema, Map.of()).isEmpty());
}
}

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.mcc.biz.cmm.converter;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableResponse;
import io.shinhanlife.dap.mcc.infra.itrf.mci.cfp.a.io.CLCNNB00001_I;
import io.shinhanlife.dap.mcc.infra.itrf.mci.cfp.a.io.CLCNNB00001_O;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* @package io.shinhanlife.dap.mcc.biz.cmm.converter
* @className MetaTableConverter
* @description AX HUB 시스템 처리 클래스
* @author 09863409
* @create 2026.07.29
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.07.29 09863409 최초생성
*
* </pre>
*/
@Mapper(componentModel = "spring")
public interface MetaTableConverter {
@Mapping(target = "csNo", source = "tableName", defaultValue = "TB_META_BAS")
CLCNNB00001_I toLegacyRequest(MetaTableRequest req);
@Mapping(target = "tableList", ignore = true)
MetaTableResponse toResponse(CLCNNB00001_O mciRes);
}

View File

@@ -0,0 +1,32 @@
package io.shinhanlife.dap.mcc.biz.cmm.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import lombok.Data;
/**
* @package io.shinhanlife.dap.mcc.biz.cmm.dto
* @className MetaTableRequest
* @description AX HUB 시스템 처리 클래스
* @author 09863409
* @create 2026.07.29
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.07.29 09863409 최초생성
*
* </pre>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MetaTableRequest {
@McpParameter(description = "테이블 물리명 키워드 (예: TB_CUST_BAS, TB_CONT)", required = false)
private String tableName;
@McpParameter(description = "테이블 논리명(한글) 키워드 (예: 고객기본, 계약)", required = false)
private String tableLogicalName;
@McpParameter(description = "스키마/소유자명 (예: DAPADM, SHLOWN)", required = false)
private String owner;
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.mcc.biz.cmm.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.util.List;
/**
* @package io.shinhanlife.dap.mcc.biz.cmm.dto
* @className MetaTableResponse
* @description AX HUB 시스템 처리 클래스
* @author 09863409
* @create 2026.07.29
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.07.29 09863409 최초생성
*
* </pre>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MetaTableResponse {
private List<MetaTableItem> tableList;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class MetaTableItem {
private String owner;
private String tableName;
private String tableLogicalName;
private String tableDesc;
private Integer columnCount;
private Long rowCount;
}
}

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
/**
* @package io.shinhanlife.dap.mcc.biz.cmm.usecase
* @className MetaTableUseCase
* @description AX HUB 시스템 처리 클래스
* @author 09863409
* @create 2026.07.29
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.07.29 09863409 최초생성
*
* </pre>
*/
@McpTool(
routingType = "MCI",
categoryKey = "cmm"
)
public interface MetaTableUseCase {
@McpFunction(
displayName = "메타 테이블 조회 툴",
name = "metaTable",
description = "메타 테이블 정보 목록을 조회해줘",
prompt = "메타 테이블 정보 목록을 조회해줘",
mappingId = "CLCNNB00001",
register = false,
requiresApproval = false,
openWorldHint = true
)
Object execute(MetaTableRequest req);
}

View File

@@ -0,0 +1,89 @@
package io.shinhanlife.dap.mcc.biz.cmm.usecase.impl;
import io.shinhanlife.dap.mcc.biz.cmm.converter.MetaTableConverter;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableRequest;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableResponse;
import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaTableResponse.MetaTableItem;
import io.shinhanlife.dap.mcc.biz.cmm.usecase.MetaTableUseCase;
import io.shinhanlife.dap.mcc.infra.itrf.mci.cfp.a.MciCfpaClient;
import io.shinhanlife.dap.mcc.infra.itrf.mci.cfp.a.io.CLCNNB00001_I;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* @package io.shinhanlife.dap.mcc.biz.cmm.usecase.impl
* @className MetaTableUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author 09863409
* @create 2026.07.29
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.07.29 09863409 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MetaTableUseCaseImpl implements MetaTableUseCase {
private final MciCfpaClient mci;
private final MetaTableConverter converter;
@Override
public Object execute(MetaTableRequest req) {
log.info("[MCI Tool] {} 요청 수신. 파라미터: {}", "metaTable", req);
try {
CLCNNB00001_I mciRequest = converter.toLegacyRequest(req);
Object mciResponse = mci.callCfpa0001(mciRequest);
log.info("[MCI Tool] CLCNNB00001 MCI call completed. Returning response status: {}",
mciResponse != null);
// 현업 테스트용으로 MCI 통신을 바이패스하고 하드코딩된 메타 테이블 샘플 결과를 반환합니다.
MetaTableResponse res = new MetaTableResponse();
List<MetaTableItem> list = new ArrayList<>();
MetaTableItem item1 = new MetaTableItem();
item1.setOwner(req.getOwner() != null ? req.getOwner() : "DAPADM");
item1.setTableName(req.getTableName() != null ? req.getTableName() : "TB_CUST_BAS");
item1.setTableLogicalName("고객기본정보");
item1.setTableDesc("고객 기본 프로필 및 인적사항 관리 테이블");
item1.setColumnCount(35);
item1.setRowCount(1250000L);
list.add(item1);
MetaTableItem item2 = new MetaTableItem();
item2.setOwner(req.getOwner() != null ? req.getOwner() : "DAPADM");
item2.setTableName("TB_CONT_MCD");
item2.setTableLogicalName("계약주계약정보");
item2.setTableDesc("보험 계약 주계약 상세 원장 테이블");
item2.setColumnCount(58);
item2.setRowCount(3400000L);
list.add(item2);
MetaTableItem item3 = new MetaTableItem();
item3.setOwner(req.getOwner() != null ? req.getOwner() : "DAPADM");
item3.setTableName("TB_CLAIM_DTL");
item3.setTableLogicalName("청구접수상세");
item3.setTableDesc("보험금 청구 접수 건별 내역 테이블");
item3.setColumnCount(42);
item3.setRowCount(890000L);
list.add(item3);
res.setTableList(list);
return res;
} catch (Exception e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
return Map.of("status", "ERROR", "message", e.getMessage() != null ? e.getMessage() : "Unknown error");
}
}
}

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.mcc.biz.sol.converter;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailRequest;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailResponse;
import io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io.SOLG00000002_I;
import io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io.SOLG00000002_O;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.converter
* @className SolReqDetailConverter
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Mapper(componentModel = "spring")
public interface SolReqDetailConverter {
@Mapping(source = "srId", target = "srId")
SOLG00000002_I toLegacyRequest(SolReqDetailRequest req);
SolReqDetailResponse toResponse(SOLG00000002_O mciRes);
}

View File

@@ -0,0 +1,27 @@
package io.shinhanlife.dap.mcc.biz.sol.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import lombok.Data;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.dto
* @className SolReqDetailRequest
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SolReqDetailRequest {
@McpParameter(description = "상세 조회할 SOL 의뢰서 ID", required = true)
private String srId;
}

View File

@@ -0,0 +1,33 @@
package io.shinhanlife.dap.mcc.biz.sol.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.dto
* @className SolReqDetailResponse
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SolReqDetailResponse {
private String srId;
private String srName;
private String process;
private String devStage;
private String appName;
private String requester;
private String requestDate;
private String dueDate;
private String description;
}

View File

@@ -0,0 +1,39 @@
package io.shinhanlife.dap.mcc.biz.sol.usecase;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailRequest;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.usecase
* @className SolReqDetailUseCase
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@McpTool(
routingType = "MCI",
categoryKey = "sol"
)
public interface SolReqDetailUseCase {
@McpFunction(
displayName = "SolReqDetail 툴",
name = "solReqDetail",
description = "SOL 의뢰서 상세 조회",
prompt = "SOL 의뢰서 상세 조회해줘",
mappingId = "SOLG00000002",
register = false,
requiresApproval = false,
readOnlyHint = true,
openWorldHint = true
)
Object execute(SolReqDetailRequest req);
}

View File

@@ -0,0 +1,101 @@
package io.shinhanlife.dap.mcc.biz.sol.usecase.impl;
import io.shinhanlife.dap.mcc.biz.sol.converter.SolReqDetailConverter;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailRequest;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailResponse;
import io.shinhanlife.dap.mcc.biz.sol.usecase.SolReqDetailUseCase;
import io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.MciNclgClient;
import io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io.SOLG00000002_I;
import io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io.SOLG00000002_O;
import io.shinhanlife.glow.communication.dto.Transfer;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.usecase.impl
* @className SolReqDetailUseCaseImpl
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SolReqDetailUseCaseImpl implements SolReqDetailUseCase {
private final MciNclgClient mci;
private final SolReqDetailConverter converter;
@Value("${sol.req-detail.mock-enabled:false}")
private boolean mockEnabled;
@Override
public Object execute(SolReqDetailRequest req) {
log.info("[MCI Tool] {} 요청 수신. 파라미터: {}", "solReqDetail", req);
if (req == null || req.getSrId() == null || req.getSrId().isBlank()) {
return Map.of("status", "ERROR", "message", "srId는 필수입니다.");
}
if (mockEnabled) {
return createLocalSampleResponse(req.getSrId());
}
try {
SOLG00000002_I mciRequest = converter.toLegacyRequest(req);
Transfer<SOLG00000002_O> mciResponse = mci.callTo(
"SOLG00000002", "SOLG00000002", mciRequest, SOLG00000002_O.class);
if (mciResponse == null || mciResponse.getBody() == null) {
return Map.of("status", "NOT_FOUND", "message", "의뢰서 상세 정보를 찾을 수 없습니다.");
}
return converter.toResponse(mciResponse.getBody());
} catch (Exception e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
return Map.of(
"status", "ERROR",
"message", e.getMessage() != null ? e.getMessage() : "Unknown error");
}
}
private Object createLocalSampleResponse(String srId) {
SolReqDetailResponse response = new SolReqDetailResponse();
if ("SR-2026-001".equalsIgnoreCase(srId)) {
response.setSrId("SR-2026-001");
response.setSrName("AX HUB 메인 화면 UI 개편");
response.setProcess("진행중");
response.setDevStage("개발(단위테스트)");
response.setAppName("AX HUB");
response.setRequester("신한준");
response.setRequestDate("2026-07-01");
response.setDueDate("2026-08-31");
response.setDescription("AX HUB 메인 화면의 사용성과 접근성을 개선하는 UI 개편 의뢰입니다.");
return response;
}
if ("SR-2026-002".equalsIgnoreCase(srId)) {
response.setSrId("SR-2026-002");
response.setSrName("SOL 연동 모듈 추가 개발");
response.setProcess("진행중");
response.setDevStage("분석/설계");
response.setAppName("MCP Gateway");
response.setRequester("고석민");
response.setRequestDate("2026-07-15");
response.setDueDate("2026-09-30");
response.setDescription("SOL 의뢰서 조회 기능을 MCP 도구로 제공하기 위한 연동 모듈 개발 의뢰입니다.");
return response;
}
return Map.of(
"status", "NOT_FOUND",
"message", "의뢰서를 찾을 수 없습니다.",
"srId", srId);
}
}

View File

@@ -0,0 +1,23 @@
package io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io;
import lombok.Data;
/**
* @package io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io
* @className SOLG00000002_I
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
public class SOLG00000002_I {
private String srId;
}

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io;
import lombok.Data;
/**
* @package io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.io
* @className SOLG00000002_O
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Data
public class SOLG00000002_O {
private String srId;
private String srName;
private String process;
private String devStage;
private String appName;
private String requester;
private String requestDate;
private String dueDate;
private String description;
}

View File

@@ -36,4 +36,8 @@ axhub:
gateway: gateway:
url: http://localhost:8081 url: http://localhost:8081
tool: tool:
url: ${AXHUB_TOOL_URL:http://localhost:${server.port}} url: ${AXHUB_TOOL_URL:http://localhost:${server.port}}
sol:
req-detail:
mock-enabled: true

View File

@@ -0,0 +1,45 @@
package io.shinhanlife.dap.mcc.biz.sol.usecase.impl;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dap.mcc.biz.sol.converter.SolReqDetailConverter;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailRequest;
import io.shinhanlife.dap.mcc.biz.sol.dto.SolReqDetailResponse;
import io.shinhanlife.dap.mcc.infra.itrf.mci.ncl.g.MciNclgClient;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
/**
* @package io.shinhanlife.dap.mcc.biz.sol.usecase.impl
* @className SolReqDetailUseCaseImplTest
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
class SolReqDetailUseCaseImplTest {
@Test
void returnsLocalSampleDetailBySrId() {
MciNclgClient mci = Mockito.mock(MciNclgClient.class);
SolReqDetailConverter converter = Mockito.mock(SolReqDetailConverter.class);
SolReqDetailUseCaseImpl useCase = new SolReqDetailUseCaseImpl(mci, converter);
ReflectionTestUtils.setField(useCase, "mockEnabled", true);
SolReqDetailRequest request = new SolReqDetailRequest();
request.setSrId("SR-2026-001");
SolReqDetailResponse response = (SolReqDetailResponse) useCase.execute(request);
assertThat(response.getSrId()).isEqualTo("SR-2026-001");
assertThat(response.getSrName()).isEqualTo("AX HUB 메인 화면 UI 개편");
Mockito.verifyNoInteractions(mci);
}
}