55 Commits

Author SHA1 Message Date
mjh
05ea5f9aaa merge: feature/20260805_mjh into feature/20260805_emis_create 2026-08-05 09:41:09 +09:00
mjh
50315ad245 feat: add ONCSC1340 MCI client and IO DTOs 2026-08-05 09:33:58 +09:00
jade
65222b5b1a feat: index.html 메인 화면에 Tester, Console 카드 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m49s
2026-08-05 00:06:02 +09:00
jade
b9f98e89ce fix: index.html 헤더 링크 추가 및 tester.html CSV 한글 깨짐 방지(BOM 추가)
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m49s
2026-08-05 00:01:59 +09:00
jade
96e308aefb fix: ZtUsacRepository (MyBatis Mapper) 의존성 주입 에러 수정 (MybatisConfig에 @MapperScan 추가)
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m52s
2026-08-04 23:56:23 +09:00
jade
96a26bc93d fix: ToolManifestService 등의 Bean 로드 에러 방지를 위해 scanBasePackages 범위를 io.shinhanlife.dap.lib 전체로 확장
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m50s
2026-08-04 23:53:58 +09:00
jade
a2586fd6d3 fix: ToolPodMcpToolSynchronizer가 dap-gateway에서도 스캔되어 발생하는 Bean 에러 수정 (usecase 패키지로 이동)
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m48s
2026-08-04 23:51:07 +09:00
jade
cdfb6ef901 fix: ToolRegistryHeartbeatSender Bean을 인식하지 못하는 런타임 에러 수정 (scanBasePackages 추가)
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m53s
2026-08-04 23:46:30 +09:00
jade
3729e2d6ee fix: McpRouterController, ChatController 불필요한 FQCN 선언 제거 및 import 통합
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m52s
2026-08-04 23:43:44 +09:00
jade
946ec6d70f chore: DapToolOthApplication, logback 등 코드 내 남아있던 이전 명칭(찌꺼기) 일괄 치환 및 정리
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m7s
2026-08-04 23:36:50 +09:00
jade
136ad2fa71 refactor: dap-tool-* 모듈을 dap-was-* 이름으로 전면 개편 및 컨테이너명/파이프라인 연동
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m57s
2026-08-04 23:33:06 +09:00
jade
3e69436bfe chore: 잘못 생성되어 있던 중복 ToolMetadata 클래스(mcg.dto 패키지) 삭제
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m54s
2026-08-04 23:10:02 +09:00
jade
f827760513 refactor: 공용 클래스(MCP, Manifest, UseCase 등) lib 패키지로 이관 및 통합
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m17s
2026-08-04 23:06:01 +09:00
jade
1a70495686 feat(ui): add tool-test-console and tester dashboards
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m51s
- Add Auto-Tester Dashboard (tester.html) in gateway for batch testing tools

- Add standalone Tool Test Console (tool-test-console.html) in core

- Fix Tailwind CSS Preflight conflicts in console UI

- Update console payload schema resolution to support both gateway and pod modes

- Strip JSON-RPC metadata from tool payload output in console

- Add unified navigation headers across all static HTML pages
2026-08-04 18:24:52 +09:00
jade
39c1f4ee12 UI: chat.html 상단 GNB 디자인을 scaffold.html과 동일하게 동기화
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m52s
2026-08-04 16:45:57 +09:00
jade
e858859021 feat: standardize tool names by pod domain action
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m55s
2026-08-04 16:39:03 +09:00
jade
a58af1606e feat: publish tool manifest endpoint
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m50s
2026-08-04 15:58:59 +09:00
jade
08ccc043da feat: organize tool schemas by category
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m58s
2026-08-04 14:42:08 +09:00
jade
4393071212 chore: log validated tool response
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m54s
2026-08-04 13:38:43 +09:00
jade
1bac8d9d87 feat: enrich claim search output schema
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m53s
2026-08-04 13:34:02 +09:00
jade
31e6fd605d feat: support MCP output schemas
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s
2026-08-04 11:24:59 +09:00
jade
15262d5a0f test: keep input schemas in tool modules
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m56s
2026-08-04 11:03:35 +09:00
jade
2957272cc3 feat: support explicit MCP input schemas
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
2026-08-04 10:50:25 +09:00
jade
52f585fa45 Refactor: Remove SensitiveDataMasker and fix PaginationRequestValidator
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m55s
2026-08-03 17:41:02 +09:00
jade
bc86f36825 feat(scaffold): 템플릿 코드에 @McpValidation 예시 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m42s
2026-08-03 16:37:11 +09:00
jade
55dcffa2c9 feat(schema): 고급 JSON Schema 제약조건(format, default, examples 등) 지원 및 McpFunction 수정 반영
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
2026-08-03 16:35:46 +09:00
jade
be0314afe8 feat(schema): @McpAnyOf 어노테이션 및 JSON Schema 파싱 로직 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m56s
2026-08-03 16:31:43 +09:00
jade
39eab0429e Revert "feat(security): ToolSecurityContext 및 KMS 복호화 공통 모듈 추가"
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s
This reverts commit cf958622bd.
2026-08-03 15:35:20 +09:00
jade
cf958622bd feat(security): ToolSecurityContext 및 KMS 복호화 공통 모듈 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
2026-08-03 13:45:16 +09:00
jade
d858f2ab90 Merge branch 'main' of https://git.devjun.net/kimhyungsik/ax_hub_mcp_tool
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m53s
2026-07-31 14:59:17 +09:00
jade
2bc896a130 application 업데이트 2026-07-31 14:59:00 +09:00
b22cd779cf Merge pull request 'fix: paser로 인해 인공지능의 띄어쓰기가 이상해지는 문제 해결' (#5) from feature/markdown into main
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m20s
Reviewed-on: #5
2026-07-31 14:48:01 +09:00
juheelee
b3908f771f fix: paser로 인해 인공지능의 띄어쓰기가 이상해지는 문제 해결 2026-07-31 14:45:31 +09:00
jade
58234b1c59 refactor: align Glow transaction field metadata
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m20s
2026-07-31 14:44:21 +09:00
jade
541d6d1938 config: centralize Glow MCI settings
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m15s
2026-07-31 14:41:35 +09:00
628925dce9 Merge pull request 'feature/markdown' (#4) from feature/markdown into main
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m58s
Reviewed-on: #4
2026-07-30 18:01:02 +09:00
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
1bf09705d1 build: validate duplicate MCP tool names before packaging
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m1s
2026-07-30 17:41:06 +09:00
jade
60055dd9ed feat: propagate MCP request headers to tools
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m28s
2026-07-30 17:10:15 +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
jade
8b4d4da0b9 Merge branch 'main' of https://git.devjun.net/kimhyungsik/ax_hub_mcp_tool into DEV-SRTEST 2026-07-29 17:02:21 +09:00
jade
c551b15378 테스트 푸쉬 2026-07-29 17:01:05 +09:00
juheelee
b448e68f45 Merge branch 'main' of https://git.devjun.net/kimhyungsik/ax_hub_mcp_tool
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m49s
2026-07-29 16:50:56 +09:00
juheelee
b68fbb9726 feat: 메타 통합 코드 조회 2026-07-29 16:48:28 +09:00
juheelee
2f5dad05df feat: 메타 통합 코드 조회 2026-07-29 15:34:01 +09:00
ea1aea81bb feat: add SOL request detail tool 2026-07-29 14:26:38 +09:00
304 changed files with 5399 additions and 742 deletions

View File

@@ -59,7 +59,7 @@ jobs:
# 4. 마운트된 /app 디렉토리로 이동하여 호스트의 도커 컴포즈 제어!
cd /app
docker system prune -f
ACTIVE_PROFILE=dev docker compose up -d --build --remove-orphans gateway redis mci-mock dozzle tool-sms tool-oth
ACTIVE_PROFILE=dev docker compose up -d --build --remove-orphans gateway redis mci-mock dozzle was-sms was-oth
# 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 자동 소거 청소!
docker image prune -f

760
README.md
View File

@@ -1,154 +1,606 @@
# DAP Backend
Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트 입니다.
---
## 아키텍처 개요 (Architecture Overview)
DAP Backend는 2개의 주요 애플리케이션으로 분리 운영됩니다:
1. **MCP Gateway (`DapGatewayApplication`)**: 외부 LLM(Claude, GPT 등) 서버의 MCP 통신을 받아, 내부 Tool 서버들로 분배(라우팅)하는 허브 서버이자 관리자 웹(Scaffolder)을 제공하는 통합 서버 (포트: 8081)
2. **MCP Tool (`DapTool*Application`)**: 실제 레거시 시스템(MCI, EAI 등)과 통신하여 비즈니스 로직(결제, 휴가신청 등)을 수행하는 어댑터 서버 (포트: 8082~8085 등 분산 구성 가능)
---
## 환경 (Environment)
| 항목 | 버전 |
|------|------|
| Java | 21 |
| Spring Boot | 4.0.5 |
| Build Tool | Gradle |
| 주요 기술 스택 | MyBatis, Lombok, MapStruct, P6Spy |
| 데이터베이스 | H2 (in-memory, 로컬 개발용) |
| 세션/캐시 저장소 | Redis |
| **장애 격리 / 제어** | **Resilience4j (RateLimiter, CircuitBreaker, Retry)** |
| **메시지 큐** | **Kafka (트래픽 폭주 시 대기열 전환용)** |
---
## ▶ 실행 방법 (How to Run)
### 1. Gateway & Tool 서버 실행 (MCP 연동용)
- **Gateway 서버 기동:**
- `./gradlew :dap-gateway:bootRun`
- **Tool 서버 기동:**
- `./gradlew :dap-tool-oth:bootRun` (또는 dap-tool-payment 등)
- Tool 서버가 기동되면 자동으로 Gateway(8081)에 자신을 등록(Auto-Registration)합니다.
- **(선택) 특정 Tool 그룹만 실행하기:**
- 업무 특성에 따라 세분화된 그룹에 속한 Tool만 띄우고 싶다면, 실행 인수에 `--mcp.tool.target=그룹명`을 추가합니다.
- **지원되는 그룹명:**
- `NOTIFICATION`: 이메일, SMS 발송
- `CLAIM`: 청구 처리, 심사 상태 조회
- `POLICY`: 증권 발행, 발행 가능 여부 조회
- `HR`: 휴가 등록, 연차 갯수 조회
- `CONTRACT`: 계약 상태, 계약 상세 조회
- `CUSTOMER`: 고객 등급, 고객 상세 정보 조회
- `SAMPLE`: 날씨, 환율, 명언 조회 등 외부 연동 샘플
- IntelliJ IDEA: `Run/Debug Configurations`에서 `DapTool*Application``Program arguments``--mcp.tool.target=NOTIFICATION` 입력
---
## 🤖 AI Agent 연동 아키텍처 (MCP & Agent Builder)
본 시스템은 **투트랙(Two-Track) AI 연동 아키텍처**를 제공하여 로컬 개발 환경과 프로덕션 환경 모두를 완벽하게 지원합니다.
### 1. 로컬 코딩 AI (Antigravity, Cursor, Claude Desktop 등) 연동
표준 MCP 통신(Stdio)을 요구하는 로컬 AI 에이전트를 위해 자바 기반의 브릿지 스크립트(`McpBridge.java`)를 내장하고 있습니다. 브릿지가 Stdio 요청을 HTTP로 변환하여 로컬 환경의 Gateway(포트: 8281)로 전달합니다.
- **설정 방법**: IDE의 `mcp_config.json` 설정 파일에 아래와 같이 등록합니다.
```json
"mcpServers": {
"dap-gateway": {
"command": "java",
"args": ["C:/절대경로/dap-backend-main/McpBridge.java"]
}
}
```
- **특정 카테고리 툴 필터링**: `McpBridge.java` 내부의 URI 파라미터(`?categoryKey=sample` 등)를 수정하여 원하는 도메인의 툴만 선택적으로 AI에게 학습시킬 수 있습니다.
### 2. 프로덕션 클라우드 AI (Google Cloud Agent Builder 등) 연동
실제 라이브 서비스에서 동작하는 클라우드 Agent Builder는 REST API 기반의 OpenAPI Spec을 요구합니다.
`dap-gateway`는 이미 **Agent Builder 규격의 REST API(`/mcp/api/v1/tools/call`)를 네이티브로 제공**하므로, 별도의 브릿지나 어댑터 없이 Endpoint URL과 Swagger(OpenAPI) 문서만 클라우드 콘솔에 등록하면 즉시 라이브 챗봇/에이전트로 서비스할 수 있습니다.
---
## 비공개 Tool 관리 및 Fallback 연동 (Visibility & Routing)
저희 시스템은 MSA 보안 및 아키텍처 원칙에 따라 Tool의 **레지스트리 등록 여부(라우팅)**와 **API 노출 여부(가시성)**를 완벽히 분리하여 관리합니다.
1. **`visible = false`**:
레지스트리에 정상적으로 등록되어 게이트웨이가 동적으로 라우팅하지만, 클라이언트에게 제공되는 `/tools/list` API 목록에서는 숨겨집니다.
2. **`register = false`**:
내부 레지스트리(Redis)에 툴 정보를 등록하지 않습니다 (외부 레지스트리를 독자적으로 사용할 경우 등).
이 경우 게이트웨이는 `application.yml`의 `mcp.gateway.fallback.routes` 설정을 참조하여 **Fallback 정적 라우팅**을 수행하므로 연동이 100% 보장됩니다.
```java
@McpFunction(
name = "secret_tool",
visible = false, // 목록 숨김 여부 (기본값: true)
register = false // 내부 Redis 등록 여부 (기본값: true)
)
```
---
## 🛡️ 시스템 안정성 및 네트워크 제어 (Resilience & Network)
MSA 및 외부 시스템(MCI) 연동 환경의 안정성을 위해 완벽한 3-Tier 방어 체계를 구축했습니다.
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 기반 트래픽 제어:**
- **Gateway 계층 (동적 방어):** Tool 등록 시 제출된 SLA 메타데이터를 기반으로 동적 CircuitBreaker 및 RateLimiter를 가동하며, 한계치 초과 시 Kafka 큐로 비동기 전환합니다.
- **Tool 계층 (정적 방어):** 레거시 커넥터 내부에 `@CircuitBreaker`, `@RateLimiter` 어노테이션 기반의 장애 전파 차단 로직이 2차적으로 가동됩니다.
---
## 모듈(Pod) 및 Tool 코드 자동 생성 (Scaffolders)
새로운 도메인의 기능을 추가할 때 발생하는 반복적인 설정(보일러플레이트, 설정 파일 복사 등)을 1초 만에 자동화하기 위해 **DAP Developer Portal (Web UI)** 및 **CLI 스캐폴더 2종**을 제공합니다.
### 1. DAP Developer Portal (Web UI) - 가장 추천하는 방식!
이제 더 이상 터미널에서 명령어를 칠 필요가 없습니다. Gateway 모듈에 내장된 웹 화면에서 빈칸만 채우면 신한라이프 패키지 개발 가이드에 맞춘 코드가 마법처럼 찍혀 나옵니다.
1. **접속 방법**: Gateway 서버 기동 후 브라우저에서 `http://localhost:8081/admin/scaffold.html` 접속
2. **Pod (모듈) 생성 탭**: 모듈명(예: hr)과 포트만 입력하면 독립적인 Spring Boot 모듈이 디렉토리부터 빌드 스크립트까지 완벽히 생성됩니다.
3. **Tool (기능) 생성 탭**: 생성된 모듈에 새로운 툴 코드를 자동으로 주입합니다.
- **MCI 연동 기반 툴 생성**: 4자리 시스템 코드(예: `nclg`)를 기반으로 알맞은 패키지에 `MciNclgClient`, `Converter`, `_I`, `_O` 파일이 정확하게 생성됩니다.
- **완벽한 보일러플레이트 자동화**: `UseCaseImpl` 내부에 컴포넌트(`Client`, `Converter`)가 자동으로 의존성 주입되며, Java 15 Text Block을 활용해 들여쓰기(Indentation)까지 완벽히 정렬된 코드를 제공합니다.
### 2. CLI 스캐폴더 (기존 터미널 방식)
웹 화면을 사용할 수 없는 환경이거나 터미널이 익숙한 경우, 아래 명령어를 통해 CLI 마법사를 사용할 수 있습니다.
### 1⃣ 새로운 Pod(모듈) 전체를 생성할 때: `PodScaffolder`
새로운 도메인(예: 결제, HR)을 위한 완전히 독립적인 Spring Boot 모듈을 생성합니다. 폴더 구조, 빌드 스크립트, 각종 프로퍼티 및 도커 설정까지 완벽하게 세팅됩니다.
```bash
# 사용법: javac로 컴파일 후 실행
javac -encoding UTF-8 dap-common/src/main/java/io/shinhanlife/dap/common/util/PodScaffolder.java
java -cp dap-common/src/main/java io.shinhanlife.dap.lib.util.PodScaffolder [모듈명] [포트번호]
# 실행 예시 (dap-tool-hr 모듈을 8086 포트로 생성)
java -cp dap-common/src/main/java io.shinhanlife.dap.lib.util.PodScaffolder hr 8086
```
### 2⃣ 생성된 모듈에 새로운 툴(Function)을 추가할 때: `ToolScaffolder`
어노테이션(`@McpTool`, `@McpFunction`)이 완벽히 달린 Service와 입출력 DTO 코드를 지정된 모듈 패키지 룰에 맞춰 자동 생성합니다.
```bash
# 사용법: javac로 컴파일 후 실행
javac -encoding UTF-8 dap-common/src/main/java/io/shinhanlife/dap/common/util/ToolScaffolder.java
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"
```
---
# AX HUB MCP Tool
신한라이프 업무 시스템과 AI Agent를 연결하는 MCP(Model Context Protocol) Gateway Tool 서버 프로젝트입니다.
Agent는 Gateway에서 Tool 목록과 입력 명세를 받고, Gateway는 권한과 정책을 확인한 뒤 Tool 서버로 요청을 전달합니다. 업무 Tool은 `DTO → UseCase → Converter → MCI/EAI Client` 구조로 레거시 시스템을 호출합니다.
## 전체 흐름
```text
AI Agent / MCP Client
MCP Gateway (dap-gateway)
├─ Tool 목록·스키마 제공
├─ Tool 권한·승인·가드레일 확인
├─ Redis Registry 및 실행 추적
└─ 대상 Tool 서버로 라우팅
Tool Server (dap-tool-sms / dap-tool-oth)
└─ BusinessToolController
UseCase → Converter → MCI/EAI Client → 레거시 시스템
```
## 현재 구조와 목표 구조
현재는 Gateway와 두 개의 Tool 애플리케이션으로 구성됩니다.
```text
현재: Gateway + SMS Tool Pod + OTH Tool Pod
목표: Gateway + 고객 Pod + 영업 Pod + 지급/납입 Pod + 알림 Pod + 인사 Pod + 공통 Pod
```
`dap-tool-oth`에는 여러 업무 카테고리가 함께 있습니다. AA 협의 후에는 부서·업무 소유권 단위로 Tool 서버, 이미지, Pod, 배포 파이프라인을 분리합니다. 이 목표 구조는 향후 전환 방향이며 현재 구현 완료 상태가 아닙니다.
## Gradle 멀티모듈
| 모듈 | 역할 | 실행 포트 |
|---|---|---:|
| `dap-gateway` | MCP 진입점, Tool Registry, 라우팅, 권한·가드레일, Chat API | 8081 |
| `dap-tool-core` | 공통 어노테이션, Controller, JSON Schema, MCI/EAI 지원, 보안·로깅 공통 기능 | 라이브러리 |
| `dap-tool-sms` | SMS/알림 Tool 서버 | 8082 |
| `dap-tool-oth` | 공통·업무·샘플·MCI Tool 서버 | 8084 |
기술 기준은 Java 21, Spring Boot 4, Gradle, Spring AI MCP Server, Redis, MapStruct, MyBatis, Resilience4j입니다.
## 환경 (Environment)
| 항목 | 버전 / 기준 |
|---|---|
| Java | 21 |
| Spring Boot | 4.0.5 |
| Gradle Wrapper | 8.14.3 |
| Spring AI BOM | 2.0.0 |
| Spring AI MCP Server | `spring-ai-starter-mcp-server-webmvc` |
| Redis Client | Lettuce 6.6.0.RELEASE |
| Resilience | Resilience4j 2.2.0 |
| MyBatis Spring Boot Starter | 3.0.3 |
| MapStruct | 1.5.5.Final |
| Lombok | 1.18.32 |
| JSON Schema Validator | networknt 1.4.0 |
| OpenAPI UI | springdoc 2.5.0 |
| 컨테이너 실행 | Docker Compose |
프로젝트는 JDK 21을 기준으로 컴파일됩니다. IntelliJ에서는 Project SDK, Gradle JVM, Run Configuration JRE를 모두 JDK 21로 맞춰야 합니다.
## 5분 빠른 시작
Docker와 JDK 21이 준비된 로컬 개발 환경 기준입니다.
```powershell
# 1. Redis와 MCI Mock만 먼저 실행
$env:ACTIVE_PROFILE = 'local'
docker compose up -d redis mci-mock
# 2. Gateway 실행 (새 PowerShell)
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:OPENROUTER_API_KEY = '<개발용 비밀 저장소의 키>'
.\gradlew.bat :dap-gateway:bootRun
# 3. OTH 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
```
Tool 서버가 기동된 뒤 아래 URL로 등록된 Tool 목록을 확인합니다.
```text
http://localhost:8081/mcp/api/v1/tools/list
```
SMS Tool도 함께 확인하려면 별도 PowerShell에서 아래 명령을 실행합니다.
```powershell
$env:SPRING_PROFILES_ACTIVE = 'local'
$env:AXHUB_GATEWAY_URL = 'http://localhost:8081'
$env:AXHUB_TOOL_URL = 'http://localhost:8082'
.\gradlew.bat :dap-tool-sms:bootRun
```
전체 컨테이너 환경이 필요하면 개별 실행 대신 다음 한 줄을 사용합니다.
```powershell
$env:ACTIVE_PROFILE = 'local'
docker compose up -d --build
```
## Tool 등록과 실행
### 등록과 목록 제공
1. Tool 서버 기동 시 `ToolRegistryHeartbeatSender``@McpTool`, `@McpFunction`을 스캔합니다.
2. Tool 이름, 설명, 입력 JSON Schema, `categoryKey`, 연동 방식, 실행 URL을 메타데이터로 생성합니다.
3. Gateway Redis Registry에 등록·Heartbeat 정보를 전송합니다.
4. Agent와 관리 화면은 Gateway에서 Tool 목록과 명세를 조회합니다.
### 실행
1. Agent가 Gateway에 Tool 이름과 입력값을 보냅니다.
2. Gateway가 Tool 존재 여부, 허용 Tool, 쓰기 승인, 가드레일을 확인합니다.
3. Gateway가 Tool 서버의 `/mcp/{toolName}`으로 요청을 전달합니다.
4. `BusinessToolController`가 Tool 메서드를 찾아 DTO로 변환하고 JSON Schema를 검증합니다.
5. UseCase가 업무 흐름을 수행합니다.
6. Converter가 업무 DTO를 인터페이스 ID 기반 MCI 요청 DTO로 변환합니다.
7. MCI/EAI Client가 레거시를 호출하고 결과를 Tool 응답으로 반환합니다.
## 주요 URL
로컬에서 Gateway를 직접 실행할 때의 기준입니다. Docker Compose를 사용하면 Gateway 호스트 포트는 `8281`입니다.
| 용도 | 메서드 | URL |
|---|---|---|
| Tool 목록 | `GET` | `http://localhost:8081/mcp/api/v1/tools/list` |
| Tool 실행 | `POST` | `http://localhost:8081/mcp/api/v1/tools/call` |
| 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}` |
| Chat 스트리밍 API | `POST` | `http://localhost:8081/api/chat` |
| Scaffold API | `POST` | `http://localhost:8081/api/v1/scaffold/pod` 또는 `/tool` |
`/mcp/sse/{categoryKey}`는 SSE 연결을 여는 전송 경로이고, `/mcp/custom/{categoryKey}`는 같은 카테고리의 MCP 요청을 처리하는 호출 경로입니다. 두 URL은 역할이 다릅니다.
## Tool 개발 표준
| 구성 요소 | 책임 |
|---|---|
| `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 및 플랫폼 운영 기준과 먼저 합의합니다.
## Input/Output Schema 작성 가이드
Tool Schema는 Agent가 Tool을 정확히 호출하고, 반환값의 의미를 일관되게 해석하도록 하는 계약입니다. 인증 정보·사번·주민번호 등 민감정보(PII)는 Input/Output Schema와 Tool 응답에 포함하지 않습니다.
### Input Schema
Input Schema는 Agent가 Tool에 전달하는 파라미터의 이름, 타입, 필수 여부, 허용값, 형식 등을 정의합니다.
적용 우선순위는 다음과 같습니다.
1. `inputSchemaResource` — 복잡한 규칙을 담은 JSON Schema 리소스
2. `inputSchema` — 어노테이션에 직접 선언한 JSON Schema
3. 요청 DTO 필드의 `@McpValidation` — 자동 JSON Schema 생성
단순한 요청 DTO는 `@McpValidation`만으로 관리합니다.
```java
public class ClaimSearchRequest {
@McpValidation(required = true, pattern = "^CLM[0-9]{13}$")
private String claimNo;
@McpValidation(minimum = 1, maximum = 100)
private Integer size;
}
```
### Output Schema
Output Schema는 Tool이 반환하는 결과의 타입과 의미를 정의합니다. `BusinessToolController`는 Tool 실행 후 반환값을 Output Schema 기준으로 검증합니다.
적용 우선순위는 다음과 같습니다.
1. `outputSchemaResource` — 조건부 필드·중첩 배열 등 복잡한 규칙을 담은 JSON Schema 리소스
2. `outputSchema` — 어노테이션에 직접 선언한 JSON Schema
3. 반환 DTO의 `@McpOutputSchema`와 필드 `@McpValidation` — 자동 JSON Schema 생성
4. 위 설정이 모두 없으면 Output Schema 검증을 수행하지 않음
따라서 단순한 응답은 별도 `outputSchemaResource` 없이 반환 DTO에 `@McpOutputSchema`를 선언하면 됩니다. `null`이 정상 값일 수 있는 필드는 `nullable = true`를 반드시 지정합니다.
```java
@McpOutputSchema
public class ClaimSearchResponse {
@McpValidation(required = true, allowedValues = {"SUCCESS", "FAILURE"})
private String resultCode;
@McpValidation(nullable = true, minimum = 0)
private Long approvedAmount;
}
```
### 복잡한 Schema는 Tool 모듈별 리소스로 관리
조건부 응답, 중첩 DTO, 배열 정렬 기준처럼 어노테이션만으로 표현하기 어려운 규칙은 Tool Core가 아니라 각 Tool 모듈의 리소스에 JSON Schema로 둡니다.
```text
src/main/resources/
└─ tool-schemas/
└─ {categoryKey}/
├─ claim-search-resource-input-schema.json
└─ claim-search-resource-output-schema.json
```
예를 들어 `categoryKey``cmm`이면 아래와 같이 선언합니다.
```java
@McpFunction(
name = "oth.cmm.claim.search",
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json"
)
public ClaimSearchResponse search(ClaimSearchRequest request) {
// ...
}
```
`inputSchemaResource``outputSchemaResource`는 복잡한 경우에만 선언합니다. 단순한 Tool까지 JSON 파일을 별도 생성할 필요는 없습니다.
### Output 설계 규칙
- 코드와 표시용 라벨을 함께 반환합니다. 예: `status` + `statusLabel`
- `null`이 정상인 값은 의미를 설명에 명시하고 DTO에는 `nullable = true`를 설정합니다.
- 조건부 필드는 어떤 조건에서 값이 존재하는지 JSON Schema에 명시합니다.
- 배열은 정렬 기준을 설명에 명시합니다. 예: `접수일 내림차순`
- 목록 응답에는 추가 조회 여부를 나타내는 `hasMore`를 포함합니다.
- 민감정보는 마스킹보다 **응답에서 제외**하는 것을 우선합니다.
### 실행 로그 및 확인
Tool 실행이 끝나면 아래 로그는 Schema 정의가 아니라 **검증을 통과한 실제 최종 응답값**을 출력합니다.
```text
[Tool -> MCP Gateway] Output Schema Result: { ... }
```
따라서 로그에도 실제 응답이 남으므로, 응답 DTO와 Output Schema에 민감정보가 포함되지 않도록 설계해야 합니다.
스키마 리소스와 DTO 기반 자동 Schema는 아래 테스트로 함께 검증할 수 있습니다.
```powershell
.\gradlew.bat :dap-tool-oth:test --tests "io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequestSchemaTest"
```
### Tool Naming Convention
All Tool names use the four-level lowercase format `pod.domain.service.action`. Do not use underscores or CamelCase; use a hyphen (`-`) only when a single level has multiple words.
- `pod`: deployment Tool Pod/module (`dap-tool-oth``oth`, `dap-tool-sms``sms`)
- `domain`: business-domain package (`cmm`, `smp`, `sol`, etc.)
- `service`: business service or resource
- `action`: the requested operation (`search`, `list`, `detail`, `issue`, `inquiry`, etc.)
```text
oth.cmm.bond.issue
oth.cmm.claim.search
oth.sol.request.list
oth.smp.weather.inquiry
```
When Scaffold receives `dap-tool-oth`, `cmm`, and `ClaimSearch`, it generates `oth.cmm.claim.search`. The `validateMcpToolNames` Gradle task rejects both a duplicate name and any name outside this format before packaging, including its source file and line number.
### Tool Test Console
각 Tool Pod는 공통 테스트 화면을 제공합니다.
```text
http://localhost:8084/tool-test-console.html
```
화면은 현재 Pod의 `/tool-manifest`에서 Tool 목록과 `inputSchema`를 읽습니다. Tool을 선택한 뒤 `Schema 샘플 채우기`로 요청 JSON을 만들고 실행할 수 있습니다. 업무에 맞게 보정한 요청은 `현재 요청 저장`으로 브라우저의 `localStorage`에 보관합니다.
`Run saved cases`는 저장된 테스트 케이스를 순차 실행해 성공/실패, HTTP 상태, 소요 시간을 보여줍니다. 따라서 Tool이 수백 개여도 각 Tool마다 테스트 화면을 만들 필요 없이, 유효한 업무 테스트 데이터만 한 번 저장하면 이후에는 몇 번의 클릭으로 회귀 테스트할 수 있습니다.
- Tool 호출은 현재 Pod의 `/mcp/{toolName}`로 수행합니다.
- 매 실행마다 `trace-id`, `request-id`를 새로 생성하여 응답과 함께 표시합니다.
- 외부 MCI/EAI Tool은 샘플값 대신 개발계에서 허용된 테스트 데이터를 저장해서 사용해야 합니다.

View File

@@ -49,3 +49,20 @@ subprojects {
useJUnitPlatform()
}
}
def toolCoreProject = project(':dap-was-lib')
tasks.register('validateMcpToolNames', JavaExec) {
group = 'verification'
description = 'Checks duplicate @McpFunction names across all Tool modules before packaging.'
dependsOn toolCoreProject.tasks.named('classes')
classpath = toolCoreProject.sourceSets.main.runtimeClasspath
mainClass.set('io.shinhanlife.dap.lib.validation.McpToolNameValidationRunner')
args rootProject.projectDir.absolutePath
}
subprojects {
tasks.matching { it.name == 'bootJar' }.configureEach {
dependsOn rootProject.tasks.named('validateMcpToolNames')
}
}

View File

@@ -3,7 +3,7 @@ plugins {
}
dependencies {
implementation project(':dap-tool-core')
implementation project(':dap-was-lib')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@@ -11,6 +11,7 @@ dependencies {
// Spring AI MCP Server
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-openai:2.0.0'
// MyBatis & DB
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

View File

@@ -16,7 +16,6 @@ package io.shinhanlife.dap.mcg.audit;
* </pre>
*/
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.guardrail.SensitiveDataMasker;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
@@ -32,11 +31,8 @@ import org.springframework.stereotype.Service;
public class AuditLogService {
private static final Logger audit = LoggerFactory.getLogger("MCP_AUDIT");
private final McpGatewayProperties properties;
private final SensitiveDataMasker masker;
public AuditLogService(McpGatewayProperties properties, SensitiveDataMasker masker) {
public AuditLogService(McpGatewayProperties properties) {
this.properties = properties;
this.masker = masker;
}
/**
@@ -47,7 +43,7 @@ public class AuditLogService {
return;
}
audit.info("event=tool_started requestId={} agentId={} userId={} clientAddress={} tool={} arguments={}",
context.requestId(), context.agentId(), context.userId(), context.clientAddress(), toolName, masker.mask(arguments));
context.requestId(), context.agentId(), context.userId(), context.clientAddress(), toolName, arguments);
}
/**

View File

@@ -1,6 +1,6 @@
package io.shinhanlife.dap.mcg.config;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;

View File

@@ -17,7 +17,7 @@ package io.shinhanlife.dap.mcg.guardrail;
*/
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Service;

View File

@@ -1,92 +0,0 @@
package io.shinhanlife.dap.mcg.guardrail;
/**
* @package io.shinhanlife.dap.mcg.guardrail
* @className SensitiveDataMasker
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Audit Log, Redis Trace, Agent 응답 preview에 남으면 안 되는 민감정보를 마스킹합니다.
*/
@Component
public class SensitiveDataMasker {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"password", "passwd", "pwd", "token", "accessToken", "refreshToken", "secret",
"ssn", "rrn", "residentNumber", "cardNumber", "accountNumber", "accountNo",
"phone", "mobile", "email", "idempotencyKey");
private static final Pattern EMAIL = Pattern.compile("([a-zA-Z0-9._%+-]{2})[a-zA-Z0-9._%+-]*(@[a-zA-Z0-9.-]+)");
private static final Pattern CARD_OR_ACCOUNT = Pattern.compile("\\b(\\d{4})\\d{4,12}(\\d{2,4})\\b");
private final ObjectMapper json;
public SensitiveDataMasker(ObjectMapper json) {
this.json = json;
}
/**
* JsonNode 전체를 재귀적으로 순회하며 민감 key와 민감 패턴을 마스킹합니다.
*/
public JsonNode mask(JsonNode input) {
if (input == null || input.isMissingNode() || input.isNull()) {
return json.createObjectNode();
}
if (input.isArray()) {
ArrayNode masked = json.createArrayNode();
for (JsonNode item : input) {
masked.add(mask(item));
}
return masked;
}
if (input.isObject()) {
ObjectNode masked = json.createObjectNode();
Iterator<Map.Entry<String, JsonNode>> fields = input.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> entry = fields.next();
String key = entry.getKey();
JsonNode value = entry.getValue();
if (isSensitiveKey(key)) {
masked.put(key, "***");
} else {
masked.set(key, mask(value));
}
}
return masked;
}
if (input.isTextual()) {
return json.valueToTree(maskText(input.asText()));
}
return input;
}
private boolean isSensitiveKey(String key) {
return key != null && SENSITIVE_KEYS.stream().anyMatch(sensitive -> sensitive.equalsIgnoreCase(key));
}
private String maskText(String value) {
if (value == null || value.isBlank()) {
return value;
}
String masked = EMAIL.matcher(value).replaceAll("$1***$2");
return CARD_OR_ACCOUNT.matcher(masked).replaceAll("$1********$2");
}
}

View File

@@ -17,7 +17,7 @@ package io.shinhanlife.dap.mcg.guardrail;
*/
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

View File

@@ -15,7 +15,8 @@ package io.shinhanlife.dap.mcg.presentation;
*
* </pre>
*/
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import lombok.RequiredArgsConstructor;
@@ -26,6 +27,7 @@ import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Flux;
@@ -55,26 +57,45 @@ public class ChatController {
try {
List<ToolCallback> callbacks = new ArrayList<>();
io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse toolsResponse = mcpRouterController.listTools(null).getBody();
JsonRpcResponse toolsResponse = mcpRouterController.listTools(null).getBody();
if (toolsResponse != null && toolsResponse.getResult() instanceof Map) {
Map<String, Object> resultMap = (Map<String, Object>) toolsResponse.getResult();
if (resultMap.containsKey("tools")) {
List<ToolMetadata> allTools = (List<ToolMetadata>) resultMap.get("tools");
Set<String> addedToolNames = new HashSet<>();
for (ToolMetadata meta : allTools) {
if (Boolean.TRUE.equals(meta.getVisible())) {
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper, effectiveTenantId));
if (Boolean.TRUE.equals(meta.getVisible()) && meta.getName() != null) {
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.")
.build();
Flux<String> responseStream = chatClient.prompt()
Flux<String> responseStream = activeChatClient.prompt()
.user(message)
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools()
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(selectedModel))
.stream()
.content();
@@ -97,7 +118,7 @@ public class ChatController {
} else if (errorMsg != null && errorMsg.contains("503")) {
emitter.send("\n\n⚠ **AI 모델 서버 혼잡 (503)**: 현재 AI 모델을 제공하는 서버에 일시적으로 접속자가 많아 지연이 발생하고 있습니다. 잠시 후 다시 시도해 주세요.");
} 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 {
emitter.send("\n[에러 발생: " + errorMsg + "]");
}

View File

@@ -20,7 +20,7 @@ import io.shinhanlife.dap.lib.adapter.dto.JsonRpcRequest;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.lib.adapter.dto.Params;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
@@ -91,7 +91,7 @@ public class McpRouterController {
try {
Object result = executeService.execute(payload, effectiveTenantId);
io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse response = new io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse();
JsonRpcResponse response = new JsonRpcResponse();
response.setJsonrpc("2.0");
response.setId(payload.containsKey("id") ? String.valueOf(payload.get("id")) : UUID.randomUUID().toString());
response.setResult(result);

View File

@@ -34,7 +34,7 @@ public class ScaffoldingController {
@PostMapping("/pod")
public String scaffoldPod(@RequestBody Map<String, String> req) {
try {
String moduleName = req.getOrDefault("moduleName", "dap-tool-oth");
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
if (!moduleName.startsWith("dap-tool-")) moduleName = "dap-tool-" + moduleName;
String port = req.getOrDefault("port", "8085");
String shortName = moduleName.replace("dap-tool-", "").replace("-", "");
@@ -57,7 +57,7 @@ public class ScaffoldingController {
String description = req.get("description");
String group = req.getOrDefault("categoryKey", req.getOrDefault("group", "COMMON"));
String routingType = req.getOrDefault("routingType", "HTTP");
String moduleName = req.getOrDefault("moduleName", "dap-tool-oth");
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
String author = req.get("author");
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
String date = req.get("date");
@@ -94,15 +94,15 @@ public class ScaffoldingController {
if (sourceDir == null) sourceDir = System.getProperty("user.dir");
File dir = new File(sourceDir);
File[] files = dir.listFiles(f -> f.isDirectory() && f.getName().startsWith("dap-tool-") && !f.getName().equals("dap-tool-core"));
File[] files = dir.listFiles(f -> f.isDirectory() && f.getName().startsWith("dap-tool-") && !f.getName().equals("dap-was-lib"));
if (files == null || files.length == 0) {
return List.of("dap-tool-oth", "dap-tool-hr", "dap-tool-sms");
return List.of("dap-was-oth", "dap-tool-hr", "dap-was-sms");
}
return Arrays.stream(files).map(File::getName).sorted().collect(Collectors.toList());
} catch (Exception e) {
return List.of("dap-tool-oth", "dap-tool-hr", "dap-tool-sms");
return List.of("dap-was-oth", "dap-tool-hr", "dap-was-sms");
}
}
}

View File

@@ -16,9 +16,8 @@ package io.shinhanlife.dap.mcg.redis;
* </pre>
*/
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.guardrail.SensitiveDataMasker;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
@@ -49,19 +48,16 @@ public class RedisToolTraceService {
private final ObjectProvider<StringRedisTemplate> redisProvider;
private final ObjectMapper json;
private final McpMonitorEventService monitorEvents;
private final SensitiveDataMasker masker;
private final Map<String, AttemptState> attemptStates = new ConcurrentHashMap<>();
public RedisToolTraceService(McpGatewayProperties properties,
ObjectProvider<StringRedisTemplate> redisProvider,
ObjectMapper json,
McpMonitorEventService monitorEvents,
SensitiveDataMasker masker) {
McpMonitorEventService monitorEvents) {
this.properties = properties;
this.redisProvider = redisProvider;
this.json = json;
this.monitorEvents = monitorEvents;
this.masker = masker;
}
/**
@@ -206,7 +202,7 @@ public class RedisToolTraceService {
arguments.fieldNames().forEachRemaining(argNames::add);
trace.put("argumentNames", argNames);
trace.put("arguments", masker.mask(arguments).toString());
trace.put("arguments", arguments.toString());
trace.put("responseSummary", responseSummary(responseText));
trace.put("timestamp", Instant.now().toString());
return json.writeValueAsString(trace);

View File

@@ -1,6 +1,6 @@
package io.shinhanlife.dap.mcg.registry;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;

View File

@@ -18,8 +18,8 @@ package io.shinhanlife.dap.mcg.security;
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import io.shinhanlife.dap.mcg.dto.OperationType;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.OperationType;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import org.springframework.stereotype.Service;
import java.util.Set;

View File

@@ -17,13 +17,12 @@ package io.shinhanlife.dap.mcg.service;
*/
import java.util.HashMap;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.RetryPolicy;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import io.shinhanlife.dap.mcg.dto.OperationType;
import io.shinhanlife.dap.lib.dto.OperationType;
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.guardrail.SensitiveDataMasker;
import io.shinhanlife.dap.mcg.guardrail.GuardrailService;
import io.shinhanlife.dap.mcg.security.McpRequestContext;
import io.shinhanlife.dap.mcg.security.McpRequestContextResolver;
@@ -58,7 +57,6 @@ public class ExecuteService {
private final ToolPlanner planner;
private final KillSwitchService killSwitchService;
private final ObjectMapper objectMapper;
private final SensitiveDataMasker dataMasker;
private final GuardrailService guardrailService;
private final McpRequestContextResolver contextResolver;
private final AuditLogService auditLogService;
@@ -76,7 +74,6 @@ public class ExecuteService {
public ExecuteService(ToolPlanner planner,
KillSwitchService killSwitchService,
ObjectMapper objectMapper,
SensitiveDataMasker dataMasker,
GuardrailService guardrailService,
McpRequestContextResolver contextResolver,
AuditLogService auditLogService,
@@ -92,7 +89,6 @@ public class ExecuteService {
this.planner = planner;
this.killSwitchService = killSwitchService;
this.objectMapper = objectMapper;
this.dataMasker = dataMasker;
this.guardrailService = guardrailService;
this.contextResolver = contextResolver;
this.auditLogService = auditLogService;
@@ -261,34 +257,33 @@ public class ExecuteService {
if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) {
targetUrl = metadata.getPodUrl();
}
String executeApiUrl = targetUrl + "/mcp/" + metadata.getName();
Map<String, String> headers = new java.util.HashMap<>();
headers.put("trace-id", context.requestId());
headers.put("request-id", java.util.UUID.randomUUID().toString());
ObjectNode pageArguments = paginationValidator.normalize(arguments);
ObjectNode pageArguments = paginationValidator.normalize(metadata, arguments);
LargeToolResponseService.Collector collector = largeResponses.newCollector(metadata.getName(), context.requestId());
while (true) {
Map<String, Object> pagePayload = objectMapper.convertValue(pageArguments, Map.class);
try {
log.info(" [ExecuteService] 요청 페이로드(마스킹 적용): {}", objectMapper.writeValueAsString(dataMasker.mask(objectMapper.valueToTree(pagePayload))));
log.info(" [ExecuteService] 요청 페이로드: {}", objectMapper.writeValueAsString(pagePayload));
} catch (Exception ignore) {}
JsonNode data = null;
try {
data = toolInvoker.invoke(pagePayload, executeApiUrl, headers);
data = toolInvoker.invoke(metadata.getName(), pagePayload, targetUrl, headers);
} catch (org.springframework.web.client.RestClientResponseException e) {
// HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString());
} catch (Exception e) {
if (executeApiUrl.contains("http://tool-")) {
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
if (targetUrl.contains("http://tool-")) {
String fallbackUrl = targetUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
try {
data = toolInvoker.invoke(pagePayload, fallbackUrl, headers);
data = toolInvoker.invoke(metadata.getName(), pagePayload, fallbackUrl, headers);
} catch (Exception ex) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
}
@@ -385,4 +380,4 @@ public class ExecuteService {
public void shutdown() {
executor.shutdownNow();
}
}
}

View File

@@ -2,7 +2,7 @@ package io.shinhanlife.dap.mcg.service;
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

View File

@@ -18,7 +18,7 @@ package io.shinhanlife.dap.mcg.sync;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServer;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.sync.CustomWebMvcSseServerTransportProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -15,7 +15,7 @@ package io.shinhanlife.dap.mcg.sync;
*
* </pre>
*/
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.service.ExecuteService;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;

View File

@@ -1,6 +1,6 @@
package io.shinhanlife.dap.mcg.sync;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
import io.modelcontextprotocol.server.McpSyncServer;
import org.slf4j.Logger;

View File

@@ -19,7 +19,6 @@ import java.util.Iterator;
import java.util.Map;
import io.shinhanlife.dap.mcg.config.AgentResponseBudgetProperties;
import io.shinhanlife.dap.mcg.guardrail.SensitiveDataMasker;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -30,12 +29,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
public class AgentResponseBudgetService {
private final AgentResponseBudgetProperties properties;
private final ObjectMapper json;
private final SensitiveDataMasker masker;
public AgentResponseBudgetService(AgentResponseBudgetProperties properties, ObjectMapper json, SensitiveDataMasker masker) {
public AgentResponseBudgetService(AgentResponseBudgetProperties properties, ObjectMapper json) {
this.properties = properties;
this.json = json;
this.masker = masker;
}
public ObjectNode apply(ObjectNode response) {
@@ -109,7 +105,7 @@ public class AgentResponseBudgetService {
}
private JsonNode budgetItem(JsonNode item, BudgetStats stats) {
JsonNode masked = masker.mask(item);
JsonNode masked = item;
if (!masked.isObject()) {
return truncateByBytes(masked, stats);
}

View File

@@ -18,7 +18,6 @@ package io.shinhanlife.dap.mcg.tool.large;
import java.util.Iterator;
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.guardrail.SensitiveDataMasker;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import org.springframework.stereotype.Service;
@@ -34,16 +33,13 @@ import java.time.Instant;
public class LargeToolResponseService {
private final McpGatewayProperties properties;
private final ObjectMapper json;
private final SensitiveDataMasker masker;
private final AgentResponseBudgetService agentBudget;
public LargeToolResponseService(McpGatewayProperties properties,
ObjectMapper json,
SensitiveDataMasker masker,
AgentResponseBudgetService agentBudget) {
this.properties = properties;
this.json = json;
this.masker = masker;
this.agentBudget = agentBudget;
}
@@ -157,8 +153,7 @@ public class LargeToolResponseService {
}
Page page = pageFrom(data);
if (!page.paginated() && pageCount == 0 && count(page.items()) <= pageSize()) {
JsonNode masked = masker.mask(data);
normalData = masked;
normalData = data;
pageCount = 1;
returnedCount = count(page.items());
totalCount = returnedCount;
@@ -271,19 +266,18 @@ public class LargeToolResponseService {
}
private JsonNode previewItem(JsonNode item) {
JsonNode masked = masker.mask(item);
long itemBytes = jsonBytes(masked);
long itemBytes = jsonBytes(item);
if (itemBytes <= properties.largeResponseMaxItemBytes()) {
return masked;
return item;
}
truncated = true;
ObjectNode preview = json.createObjectNode();
preview.put("truncated", true);
preview.put("originalBytes", itemBytes);
preview.put("maxItemBytes", properties.largeResponseMaxItemBytes());
if (masked.isObject()) {
if (item.isObject()) {
ArrayNode fieldNames = json.createArrayNode();
Iterator<String> fieldNamesIter = masked.fieldNames();
Iterator<String> fieldNamesIter = item.fieldNames();
while (fieldNamesIter.hasNext()) {
fieldNames.add(fieldNamesIter.next());
}

View File

@@ -15,6 +15,7 @@ package io.shinhanlife.dap.mcg.tool.large;
*
* </pre>
*/
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
@@ -40,12 +41,18 @@ public class PaginationRequestValidator {
/**
* pageSize/cursor를 검증한 뒤 Tool 서버에 넘길 안전한 arguments 복사본을 만듭니다.
*/
public ObjectNode normalize(ObjectNode arguments) {
public ObjectNode normalize(ToolMetadata metadata, ObjectNode arguments) {
try {
ObjectNode normalized = arguments == null
? json.createObjectNode()
: (ObjectNode) json.readTree(json.writeValueAsString(arguments));
normalizePageSize(normalized);
if (metadata != null && metadata.allowedArguments().contains("pageSize")) {
normalizePageSize(normalized);
} else if (normalized.has("pageSize")) {
normalizePageSize(normalized);
}
validateCursor(normalized);
return normalized;
} catch (ToolExecutionException error) {

View File

@@ -15,7 +15,6 @@ package io.shinhanlife.dap.mcg.tool.result;
*
* </pre>
*/
import io.shinhanlife.dap.mcg.guardrail.SensitiveDataMasker;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -34,11 +33,8 @@ public class ToolExecutionResultFormatter {
private static final int TEXT_PREVIEW_LIMIT = 2_000;
private final ObjectMapper json;
private final SensitiveDataMasker masker;
public ToolExecutionResultFormatter(ObjectMapper json, SensitiveDataMasker masker) {
public ToolExecutionResultFormatter(ObjectMapper json) {
this.json = json;
this.masker = masker;
}
/**
@@ -71,7 +67,7 @@ public class ToolExecutionResultFormatter {
}
public ToolExecutionResult fromJson(String toolName, JsonNode parsed, long sizeBytes) {
JsonNode masked = masker.mask(parsed);
JsonNode masked = parsed;
if (masked.isObject()) {
ObjectNode object = (ObjectNode) masked;
if (object.path("isError").asBoolean(false) || object.has("error") || object.has("failureType")) {

View File

@@ -1,69 +1,81 @@
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.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.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
@Component
public class HttpToolInvoker implements ToolInvoker {
private final RestClient restClient;
private final ObjectMapper objectMapper;
public HttpToolInvoker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.restClient = RestClient.create();
}
@Override
public JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers) {
try {
RestClient.RequestBodySpec requestSpec = restClient.post()
.uri(targetUrl)
.contentType(MediaType.APPLICATION_JSON);
if (headers != null) {
headers.forEach(requestSpec::header);
public JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers) {
String endpoint = podUrl.replaceAll("/+$", "") + "/mcp";
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
if (headers != null) {
headers.forEach(requestBuilder::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));
}
Object httpResult = requestSpec.body(payload)
.retrieve()
.body(Object.class);
return extractData(objectMapper.valueToTree(httpResult));
} catch (Exception e) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 호출 실패: " + e.getMessage(), e);
return result.structuredContent() != null
? objectMapper.valueToTree(result.structuredContent())
: textContentAsJson(result);
} catch (ToolExecutionException error) {
throw error;
} catch (Exception error) {
throw new ToolExecutionException(FailureType.SERVER_ERROR,
"Tool Pod MCP call failed: " + error.getMessage(), error);
}
}
private JsonNode extractData(JsonNode root) {
if (!root.path("success").asBoolean(true)) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText());
private JsonNode textContentAsJson(McpSchema.CallToolResult result) {
String text = textContent(result);
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의 최소 공통 인터페이스입니다.
*/
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,10 +10,10 @@
mcp:
gateway:
fallback:
default-url: http://tool-oth:8084
default-url: http://was-oth:8084
routes:
sms: http://tool-sms:8082
hr: http://tool-oth:8084
sms: http://was-sms:8082
hr: http://was-oth:8084
# --- 신한라이프 EAI/MCI 연계 IP 정보 (개발 환경) ---
shinhan:

View File

@@ -10,11 +10,11 @@ spring:
timeout-per-shutdown-phase: 20s
ai:
openai:
api-key: AQ.Ab8RN6KFZggsQf8iooY1v_3h3vp2TIjiYB54dV4Yay3vVKEMtg
base-url: https://generativelanguage.googleapis.com/v1beta/openai/
api-key: ${OPENROUTER_API_KEY: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}
base-url: https://openrouter.ai/api/v1
chat:
options:
model: gemini-flash-latest
model: google/gemma-4-31b-it:free
temperature: 0.3
server:
@@ -26,10 +26,10 @@ server:
mcp:
gateway:
fallback:
default-url: http://tool-oth:8084
default-url: http://was-oth:8084
routes:
sms: http://tool-sms:8082
hr: http://tool-oth:8084
sms: http://was-sms:8082
hr: http://was-oth:8084
agent-claims-required: false
trusted-claims-required: false
write-approval-required: false

View File

@@ -381,6 +381,8 @@
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center">

View File

@@ -70,6 +70,8 @@
<a href="/catalog.html" style="color:#ffffff;" class="font-semibold">Catalog</a>
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center">

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AX HUB - ChatClient</title>
<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">
<style>
body { font-family: 'Inter', sans-serif; background-color: #0f1115; color: #e2e8f0; }
@@ -25,12 +26,51 @@
0%, 100% { transform: translateY(0); }
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>
</head>
<body class="h-screen flex flex-col items-center justify-center p-4">
<body class="h-screen flex flex-col bg-[#0f1115]">
<!-- Top Navigation -->
<header style="border-bottom: 1px solid #27272a; background: rgba(9,9,11,0.85); backdrop-filter: blur(16px);" class="sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center space-x-5">
<a href="/index.html" class="flex items-center group">
<div class="w-2 h-2 rounded-full mr-2" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
<span class="font-semibold tracking-tight text-sm" style="color:#f4f4f5;">AXHUB Gateway</span>
</a>
<div class="h-4 w-px" style="background:#27272a;"></div>
<nav class="flex space-x-5 text-[13px] font-medium">
<a href="/admin/scaffold.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Scaffold</a>
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
<a href="/chat.html" style="color:#ffffff;" class="font-semibold">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center">
<span class="text-[10px] uppercase tracking-widest px-2 py-1 rounded font-bold" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">v0.0.1</span>
</div>
</div>
</header>
<!-- Chat Container -->
<div class="w-full max-w-3xl h-[85vh] flex flex-col bg-[#16181d] rounded-2xl shadow-2xl overflow-hidden border border-white/5 relative">
<!-- Main Content Area -->
<main class="flex-1 flex flex-col items-center justify-center p-4 w-full">
<!-- Chat Container -->
<div class="w-full max-w-3xl w-full flex-1 max-h-[85vh] flex flex-col bg-[#16181d] rounded-2xl shadow-2xl overflow-hidden border border-white/5 relative">
<!-- Header -->
<div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-[#16181d] z-10">
@@ -43,12 +83,23 @@
<p class="text-[11px] text-slate-500">Powered by Mock Engine & MCP Tools</p>
</div>
</div>
<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 class="flex items-center gap-4">
<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">
<option value="inclusionai/ling-3.0-flash:free" selected>Ling 3.0 Flash ⭐ (기본 - OpenRouter)</option>
<option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option>
<option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option>
<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>
@@ -85,6 +136,7 @@
</div>
</div>
</main>
<!-- 로딩 인디케이터 템플릿 -->
<template id="loading-template">
@@ -139,7 +191,7 @@
</div>
<div class="flex flex-col gap-1 max-w-[80%]">
<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>
`;
chatBox.appendChild(div);
@@ -173,14 +225,67 @@
.replace(/'/g, "&#039;");
}
function removeMarkdownEmphasis(text) {
return text.replace(/\*\*/g, '');
function sanitizeRenderedHtml(html) {
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));
if (dataLines.length > 0) onData(dataLines.join('\n'));
}
return buffer;
}
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isLoading) return;
const selectedModel = document.getElementById('model-select').value;
// 1. 유저 메시지 추가
appendUserMessage(text);
chatInput.value = '';
@@ -197,7 +302,7 @@
'Content-Type': 'application/json',
'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');
@@ -205,26 +310,24 @@
// 4. 로딩 숨기고 봇 응답 컨테이너 생성
hideLoading();
const textContainer = appendBotMessageContainer();
let responseText = '';
// 5. 스트림 읽기
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let responseText = '';
let sseBuffer = '';
const appendResponse = (data) => {
responseText += data;
renderMarkdown(textContainer, responseText);
scrollToBottom();
};
while (true) {
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;
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) {

View File

@@ -101,6 +101,8 @@
<a href="/catalog.html" class="text-zinc-400 hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" class="text-zinc-400 hover:text-white transition-colors">Playground</a>
<a href="/chat.html" class="text-zinc-400 hover:text-white transition-colors">Chat</a>
<a href="/tester.html" class="text-zinc-400 hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" class="text-zinc-400 hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center">
@@ -122,7 +124,7 @@
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 w-full max-w-6xl">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full max-w-6xl">
<!-- Scaffold Card -->
<a href="/admin/scaffold.html" class="card-panel p-8 group block">
<div class="w-12 h-12 icon-box rounded-xl flex items-center justify-center mb-6 text-zinc-300">
@@ -178,6 +180,34 @@
Start Chatting <svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
</div>
</a>
<!-- Tester Card -->
<a href="/tester.html" class="card-panel p-8 group block">
<div class="w-12 h-12 icon-box rounded-xl flex items-center justify-center mb-6 text-zinc-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path></svg>
</div>
<h2 class="text-lg font-semibold text-zinc-100 mb-3">Auto Tester</h2>
<p class="text-[13px] text-zinc-400 leading-relaxed">
Batch execute all registered tools with auto-generated dummy data to verify stability. Export results to CSV for reporting.
</p>
<div class="mt-6 flex items-center text-[12px] font-semibold text-zinc-300 group-hover:text-blue-400 group-hover:translate-x-1 transition-all">
Run Tests <svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
</div>
</a>
<!-- Console Card -->
<a href="/tool-test-console.html" class="card-panel p-8 group block">
<div class="w-12 h-12 icon-box rounded-xl flex items-center justify-center mb-6 text-zinc-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
</div>
<h2 class="text-lg font-semibold text-zinc-100 mb-3">Test Console</h2>
<p class="text-[13px] text-zinc-400 leading-relaxed">
Advanced developer console for executing tools with custom JSON payloads. Monitor real-time logs and debug application state.
</p>
<div class="mt-6 flex items-center text-[12px] font-semibold text-zinc-300 group-hover:text-blue-400 group-hover:translate-x-1 transition-all">
Open Console <svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
</div>
</a>
</div>
</main>
</body>

View File

@@ -112,6 +112,8 @@
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" style="color:#ffffff;" class="font-semibold">Playground</a>
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center space-x-3">

View File

@@ -0,0 +1,347 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tool Auto Tester</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { font-family: 'Geist', sans-serif; }
body { background-color: #09090b; color: #f4f4f5; }
.geist-mono { font-family: 'Geist Mono', monospace; }
.btn-execute {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #ffffff;
font-weight: 600;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
.btn-execute:hover { box-shadow: 0 0 30px rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
.btn-execute:disabled { background: #27272a; color: #52525b; box-shadow: none; transform: none; cursor: not-allowed; }
.btn-sm {
background: #27272a;
color: #d4d4d8;
font-weight: 500;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
border: 1px solid #3f3f46;
cursor: pointer;
transition: background 0.1s;
}
.btn-sm:hover { background: #3f3f46; color: white; }
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
.card { background: #18181b; border: 1px solid #3f3f46; border-radius: 12px; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { text-align: left; padding: 12px 16px; border-bottom: 1px solid #3f3f46; color: #a1a1aa; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.table td { padding: 12px 16px; border-bottom: 1px solid #27272a; vertical-align: middle; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background-color: rgba(255,255,255,0.02); }
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
}
.status-idle { background: rgba(113, 113, 122, 0.15); color: #a1a1aa; border: 1px solid rgba(113, 113, 122, 0.3); }
.status-running { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
.status-success { background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.3); }
.status-error { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.cat-badge {
font-family: 'Geist Mono', monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: #27272a;
color: #a1a1aa;
border: 1px solid #3f3f46;
}
</style>
</head>
<body class="min-h-screen" style="overflow-y: scroll;">
<header style="border-bottom: 1px solid #27272a; background: rgba(9,9,11,0.85); backdrop-filter: blur(16px);" class="sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center space-x-5">
<a href="/index.html" class="flex items-center group">
<div class="w-2 h-2 rounded-full mr-2" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
<span class="font-semibold tracking-tight text-sm" style="color:#f4f4f5;">AXHUB Gateway</span>
</a>
<div class="h-4 w-px" style="background:#27272a;"></div>
<nav class="flex space-x-5 text-[13px] font-medium">
<a href="/admin/scaffold.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Scaffold</a>
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
</nav>
</div>
<div class="flex items-center">
<span class="text-[10px] uppercase tracking-widest px-2 py-1 rounded font-bold" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">v0.0.1</span>
</div>
</div>
</header>
<main class="max-w-5xl mx-auto px-6 py-10">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold tracking-tight mb-2" style="color:#f4f4f5;">Auto-Tester Dashboard</h1>
<p class="text-sm" style="color:#a1a1aa;">Batch execute all registered tools with auto-generated dummy data to verify stability.</p>
</div>
<div class="flex items-center gap-3">
<button id="exportCsvBtn" class="btn-sm flex items-center gap-2 h-10 px-4 text-sm" onclick="exportCsv()" style="display: none;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Export CSV
</button>
<button id="runAllBtn" class="btn-execute flex items-center gap-2" onclick="runAllTests()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Run All Tests
</button>
</div>
</div>
<div class="card overflow-hidden">
<div class="bg-[#09090b] px-6 py-4 border-b border-[#27272a] flex items-center justify-between">
<div class="text-sm font-semibold text-slate-300">Tool List</div>
<div class="text-xs text-slate-500 geist-mono" id="progressInfo">Total: 0 / Completed: 0</div>
</div>
<div class="overflow-x-auto">
<table class="table" id="toolsTable">
<thead>
<tr>
<th style="width:10%">Category</th>
<th style="width:25%">Tool Name</th>
<th style="width:25%">Auto-Generated Payload</th>
<th style="width:20%">Status</th>
<th style="width:20%" class="text-right">Action</th>
</tr>
</thead>
<tbody id="toolsTbody">
<tr>
<td colspan="5" class="text-center text-zinc-500 py-8">Loading tools...</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
<script>
let allTools = [];
let isRunningAll = false;
async function fetchTools() {
try {
const response = await fetch('/mcp/api/v1/tools/list');
const data = await response.json();
allTools = data.result?.tools || [];
renderTable();
updateProgress(0);
} catch (error) {
document.getElementById('toolsTbody').innerHTML = `<tr><td colspan="5" class="text-center text-red-400 py-8">Failed to load tools. Gateway might be down.</td></tr>`;
}
}
function generateDummyPayload(schema) {
if (!schema || !schema.properties) return {};
const payload = {};
for (const [key, value] of Object.entries(schema.properties)) {
if (value.example !== undefined) {
payload[key] = value.example;
} else if (value.examples && Array.isArray(value.examples) && value.examples.length > 0) {
payload[key] = value.examples[0];
} else if (value.default !== undefined) {
payload[key] = value.default;
} else {
if (value.type === 'string') {
if (value.enum && value.enum.length > 0) payload[key] = value.enum[0];
else payload[key] = "test_string";
} else if (value.type === 'integer' || value.type === 'number') {
payload[key] = value.minimum !== undefined ? value.minimum : 1;
} else if (value.type === 'boolean') {
payload[key] = true;
} else {
payload[key] = null;
}
}
}
return payload;
}
function renderTable() {
const tbody = document.getElementById('toolsTbody');
if (allTools.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-zinc-500 py-8">No tools found.</td></tr>`;
return;
}
allTools.sort((a, b) => (a.categoryKey || '').localeCompare(b.categoryKey || ''));
let html = '';
allTools.forEach((tool, index) => {
const payload = generateDummyPayload(tool.parametersSchema);
const payloadStr = JSON.stringify(payload);
const shortPayload = payloadStr.length > 30 ? payloadStr.substring(0, 30) + '...' : payloadStr;
html += `
<tr id="row-${index}">
<td><span class="cat-badge">${tool.categoryKey || 'oth'}</span></td>
<td class="font-medium text-slate-200">${tool.name}</td>
<td class="text-xs text-zinc-400 geist-mono" title='${payloadStr}'>${shortPayload}</td>
<td id="status-${index}"><span class="status-badge status-idle geist-mono">IDLE</span></td>
<td class="text-right">
<button class="btn-sm run-single-btn" onclick="runSingleTest(${index})">Test Single</button>
</td>
</tr>
`;
});
tbody.innerHTML = html;
}
function updateProgress(completed) {
document.getElementById('progressInfo').textContent = `Total: ${allTools.length} / Completed: ${completed}`;
}
async function executeCall(toolName, payload, index) {
const statusCell = document.getElementById(`status-${index}`);
statusCell.innerHTML = `<span class="status-badge status-running geist-mono">RUNNING...</span>`;
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: toolName, arguments: payload }, id: Date.now() };
const startTime = Date.now();
try {
const response = await fetch('/mcp/api/v1/tools/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Agent-Id': 'AUTO-TESTER' },
body: JSON.stringify(reqPayload)
});
const latency = Date.now() - startTime;
allTools[index]._latency = latency;
if (response.ok) {
// API layer might return 200 OK but contain an error message in MCP format.
const data = await response.json();
let respData = data;
if (data?.result?.result) {
respData = data.result.result.data !== undefined ? data.result.result.data : data.result.result;
} else if (data?.error) {
respData = data.error;
}
allTools[index]._responseData = respData;
if (data.result && data.result.isError) {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">ERR: MCP ERROR &middot; ${latency}ms</span>`;
allTools[index]._lastStatus = "ERR: MCP ERROR";
} else {
statusCell.innerHTML = `<span class="status-badge status-success geist-mono">200 OK &middot; ${latency}ms</span>`;
allTools[index]._lastStatus = "200 OK";
}
} else {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">HTTP ${response.status} &middot; ${latency}ms</span>`;
allTools[index]._lastStatus = `HTTP ${response.status}`;
}
} catch (error) {
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">NET ERR</span>`;
allTools[index]._lastStatus = "NET ERR";
allTools[index]._latency = 0;
}
}
async function runSingleTest(index) {
const tool = allTools[index];
const payload = generateDummyPayload(tool.parametersSchema);
const btn = document.querySelector(`#row-${index} .run-single-btn`);
btn.disabled = true;
await executeCall(tool.name, payload, index);
btn.disabled = false;
}
async function runAllTests() {
if (isRunningAll) return;
isRunningAll = true;
const btn = document.getElementById('runAllBtn');
btn.disabled = true;
btn.innerHTML = `<div class="spinner border-[2px]" style="width:16px;height:16px;border-top-color:white;margin-right:8px;"></div> Running...`;
// Reset statuses
for (let i = 0; i < allTools.length; i++) {
document.getElementById(`status-${i}`).innerHTML = `<span class="status-badge status-idle geist-mono">IDLE</span>`;
}
let completed = 0;
// Execute in batches to avoid overwhelming the gateway/services
const batchSize = 3;
for (let i = 0; i < allTools.length; i += batchSize) {
const batch = allTools.slice(i, i + batchSize);
const promises = batch.map((tool, idx) => {
const actualIndex = i + idx;
const payload = generateDummyPayload(tool.parametersSchema);
return executeCall(tool.name, payload, actualIndex).then(() => {
completed++;
updateProgress(completed);
});
});
await Promise.all(promises);
}
isRunningAll = false;
btn.disabled = false;
btn.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Run All Tests`;
// Show Export CSV button after all tests run
document.getElementById('exportCsvBtn').style.display = 'flex';
}
function exportCsv() {
if (allTools.length === 0) return;
let csvContent = "Category,Tool Name,Status,Latency(ms),Payload,Response\n";
allTools.forEach(tool => {
const category = `"${(tool.categoryKey || 'oth').replace(/"/g, '""')}"`;
const name = `"${(tool.name || '').replace(/"/g, '""')}"`;
const status = `"${(tool._lastStatus || 'NOT RUN').replace(/"/g, '""')}"`;
const latency = tool._latency || 0;
const payload = generateDummyPayload(tool.parametersSchema);
const payloadStr = `"${JSON.stringify(payload).replace(/"/g, '""')}"`;
const resp = tool._responseData ? JSON.stringify(tool._responseData) : "";
const responseStr = `"${resp.replace(/"/g, '""')}"`;
csvContent += `${category},${name},${status},${latency},${payloadStr},${responseStr}\n`;
});
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `mcp_tools_test_report_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
document.addEventListener('DOMContentLoaded', fetchTools);
</script>
</body>
</html>

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

@@ -5,7 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcg.dto.ToolMetadata;
import io.shinhanlife.dap.lib.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.sync.RegistryMcpToolSpecificationFactory;
import org.junit.jupiter.api.Test;

View File

@@ -1,74 +0,0 @@
package io.shinhanlife.dap.mcc.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.Map;
/**
* @package io.shinhanlife.dap.mcc.dto
* @className ToolMetadata
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ToolMetadata {
// 1. Tool 기본 정보
private String uid; // UUID 형식의 고유 식별자
private String semver; // 버전 (예: 1.0.0)
private String displayName; // 사람이 읽는 라벨 (1-128자)
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
private Map<String, Object> parametersSchema;
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
private Map<String, String> actionPrompts;
// 2-1. 도메인 부서 그룹명 (category_key, 슬러그 형식)
private String categoryKey;
private String endpoint;
private String podUrl;
private String integrationType;
private String mciServiceId;
@Builder.Default
private Boolean visible = true;
@Builder.Default
private Boolean isRegistered = true;
@Builder.Default
private Boolean requiresApproval = false;
@Builder.Default
private Boolean readOnlyHint = false;
@Builder.Default
private Boolean destructiveHint = false;
@Builder.Default
private Boolean idempotentHint = false;
@Builder.Default
private Boolean openWorldHint = false;
}

View File

@@ -1,87 +0,0 @@
package io.shinhanlife.dap.mcg.dto;
import lombok.Getter;
import lombok.Setter;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Tool(Agent)의 명세 및 라우팅 정보를 담고 있는 메타데이터 클래스
* Redis 레지스트리에 저장되며, Planner와 Router 간의 통신 객체(Plan)로 사용됩니다.
*/
/**
* @package io.shinhanlife.dap.mcg.dto
* @className ToolMetadata
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class ToolMetadata {
// 1. Tool 기본 정보
private String uid; // UUID 형식의 고유 식별자
private String semver; // 버전 (예: 1.0.0)
private String displayName; // 사람이 읽는 라벨 (1-128자)
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
private Map<String, Object> parametersSchema;
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
private Map<String, String> actionPrompts;
// 2-1. 도메인 부서 그룹명 (category_key, 슬러그 형식)
private String categoryKey;
// 2-2. 툴 처리 엔드포인트 URI 경로 (예: /api/tool/customer-info)
private String endpoint;
// 2-3. Pod 실행 URL (독립적인 Microservice 라우팅용, 예: http://localhost:8082)
private String podUrl;
// 2-4. 가시성 여부
@Builder.Default
private Boolean visible = true;
// 2-5. Redis 등록 여부 (UI 표출용)
@Builder.Default
private Boolean isRegistered = true;
// 2-6. HITL 승인 필요 여부
@Builder.Default
private Boolean requiresApproval = false;
// 3. 연동 아키텍처 구분 (DIRECT / MCI_EAI)
private String integrationType; // 연동 타입: "DIRECT" 또는 "MCI_EAI"
// 4. 레거시(MCI/EAI) 연동 시 필수 정보 (integrationType이 "MCI_EAI"일 때 사용)
private String mciServiceId; // MCI/EAI 호출을 위한 서비스 ID (예: CRM_001, LICO_992)
// 5. 인프라 상태 정보 (DIRECT 연동 시 사용)
private Long lastHeartbeat; // Redis TTL 갱신용 마지막 하트비트 타임스탬프
// 6. 동적 서킷 브레이커 & 속도 제어 설정 (Registry 기반)
private Integer failureRateThreshold; // 서킷 브레이커 동작 기준 실패율 (%)
private Integer slidingWindowSize; // 서킷 브레이커 에러율 계산 표본 요청 수
private Integer rateLimitForPeriod; // 속도 제어: 1초당 허용 최대 요청 수
}

View File

@@ -0,0 +1,36 @@
package io.shinhanlife.dap.mcc.infra.itrf.mci.ncs.c;
import io.shinhanlife.dap.lib.integration.mci.component.AxhubMciComponent;
import io.shinhanlife.glow.communication.dto.Transfer;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* @package io.shinhanlife.dap.mcc.infra.itrf.mci.ncs.c
* @className MciNcsCClient
* @description 개인고객정보상세조회 MCI 호출 클라이언트
* @author KDK
* @create 2026.08.03
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.08.03 KDK 최초생성
*
* </pre>
*/
@Component
@RequiredArgsConstructor
public class MciNcsCClient {
private final AxhubMciComponent mci;
public <T> Transfer<T> callTo(
String interfaceId,
String dummy,
Object mciReq,
Class<T> resType
) throws Exception {
return mci.callTo(interfaceId, dummy, mciReq, resType);
}
}

View File

@@ -0,0 +1,43 @@
package io.shinhanlife.dap.mcc.infra.itrf.mci.ncs.c.io;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.shinhanlife.glow.communication.annotation.GlowTrgmField;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* @package io.shinhanlife.dap.mcc.infra.itrf.mci.ncs.c
* @className ONCSC1340_I
* @description 개인고객정보상세조회 InDto
* @author
* @create 2026.08.04
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
*
* </pre>
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ONCSC1340_I {
@GlowTrgmField(order = 1, length = 153, description = "개인고객정보상세조회InDto")
private IndvCsinDtptInqrInDto indvCsinDtptInqrInDto;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class IndvCsinDtptInqrInDto {
@GlowTrgmField(order = 1, length = 3, description = "조회구분코드")
private String inqrScCd;
@GlowTrgmField(order = 2, length = 150, description = "고객조회내용")
private String cstInqrCt;
}
}

View File

@@ -24,7 +24,8 @@ dependencies {
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml: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.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* MCP 스키마 생성 시 anyOf (해당 필드들 중 최소 1개 이상 필수) 제약을 부여합니다.
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface McpAnyOf {
/**
* anyOf 제약에 포함될 필드명 목록
* 예: @McpAnyOf({"claimNo", "contractNo"})
*/
String[] value();
}

View File

@@ -26,11 +26,30 @@ public @interface McpFunction {
String description();
String prompt() default "";
String mappingId() default "";
// 추가: 해당 함수가 요구하는 비즈니스 파라미터(JSON 형태의 properties) 정의
/**
* Tool 입력 JSON Schema를 인라인으로 지정한다. 지정하지 않으면 요청 DTO에서 자동 생성한다.
*/
String inputSchema() default "{}";
/**
* 복합 조건(anyOf ) 필요한 Tool의 입력 JSON Schema 클래스패스 경로다.
* inputSchemaResource가 지정되면 inputSchema DTO 자동 생성보다 우선한다.
*/
// 추가: Redis 자동 등록 Heartbeat 대상 여부 제어
String inputSchemaResource() default "";
/**
* Tool response JSON Schema. When unset, output validation is skipped.
*/
String outputSchema() default "{}";
/**
* Classpath resource for a complex Tool response JSON Schema.
* This value has priority over outputSchema.
*/
String outputSchemaResource() default "";
boolean register() default false;
// 추가: 목록 노출 여부 제어 (false 라우팅은 되나 목록에서 숨김)
@@ -44,5 +63,14 @@ public @interface McpFunction {
boolean idempotentHint() default false;
boolean openWorldHint() default false;
/** Version exposed as _meta.version in the Tool Manifest. */
String version() default "1.0.0";
/** Maximum execution time exposed as _meta.timeoutMillis in the Tool Manifest. */
long timeoutMillis() default 300000L;
/** Whether the Tool is available for MCP exposure. */
boolean enabled() default true;
// 추가: 기본 Timeout 설정 (기본 300초 = 300000ms)
}

View File

@@ -0,0 +1,17 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a Tool response DTO for automatic output JSON Schema generation.
* Field constraints are declared with {@link McpValidation}.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface McpOutputSchema {
}

View File

@@ -26,5 +26,11 @@ public @interface McpValidation {
boolean required() default false;
String pattern() default "";
long minimum() default Long.MIN_VALUE;
long maximum() default Long.MAX_VALUE;
int minLength() default -1;
int maxLength() default -1;
String[] allowedValues() default {};
String format() default "";
boolean nullable() default false; String defaultValue() default "";
String[] examples() default {};
}

View File

@@ -26,5 +26,12 @@ import java.util.Map;
public class McpProperties {
private String namespace;
private Manifest manifest = new Manifest();
@Data
public static class Manifest {
private String bundleId;
private String namePrefix;
}
}

View File

@@ -21,7 +21,11 @@ import org.springframework.context.annotation.Configuration;
*
* </pre>
*/
import org.mybatis.spring.annotation.MapperScan;
import io.shinhanlife.glow.GlowMybatisMapper;
@Configuration
@MapperScan(basePackages = "io.shinhanlife.dap", annotationClass = GlowMybatisMapper.class)
public class MybatisConfig {
@Bean

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.dap.lib.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Common MCP Tool Schema Bean configuration.
*/
@Configuration
public class ToolSchemaConfiguration {
@Bean
public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) {
return new ToolSchemaResolver(objectMapper);
}
}

View File

@@ -1,4 +1,4 @@
package io.shinhanlife.dap.mcg.dto;
package io.shinhanlife.dap.lib.dto;
/**
* @package io.shinhanlife.dap.mcg.dto

View File

@@ -1,4 +1,4 @@
package io.shinhanlife.dap.mcg.dto;
package io.shinhanlife.dap.lib.dto;
import lombok.Getter;
import lombok.Setter;
@@ -62,6 +62,10 @@ public class ToolMetadata {
// 2-4. 가시성 여부
@Builder.Default
private Boolean visible = true;
// 활성화 여부
@Builder.Default
private Boolean enabled = true;
// 2-5. Redis 등록 여부 (UI 표출용)
@Builder.Default

Some files were not shown because too many files have changed in this diff Show More