Compare commits
329 Commits
993ced0813
...
feature/to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2723d12568 | ||
|
|
df03037d3f | ||
|
|
fc235f05b9 | ||
|
|
97d380afe0 | ||
|
|
bd4d2a7624 | ||
|
|
b39c66000a | ||
|
|
11d78a0119 | ||
|
|
1d4e8b8ebd | ||
|
|
a661e07a9f | ||
|
|
4ecff183c6 | ||
|
|
999b4ebae0 | ||
|
|
69e89e0d52 | ||
|
|
6aa62a5499 | ||
|
|
017812cd29 | ||
|
|
97a56efddf | ||
|
|
39c59be5a2 | ||
|
|
ccabd1187b | ||
|
|
3e553441ba | ||
|
|
b60b933a08 | ||
|
|
8cbdd2ecad | ||
|
|
6662641903 | ||
|
|
0e27937687 | ||
|
|
45e4332adb | ||
|
|
11f94cf721 | ||
|
|
65040bdc8a | ||
|
|
fde039066c | ||
|
|
c95b946449 | ||
|
|
9c420dc5ab | ||
|
|
95a8ae3a1b | ||
|
|
9021154481 | ||
|
|
e250b1149c | ||
|
|
494fbe9c45 | ||
|
|
68608569ce | ||
|
|
d3ceec615f | ||
|
|
8312eee1d1 | ||
|
|
041f939c33 | ||
|
|
4275f76104 | ||
|
|
40303ee9b8 | ||
|
|
4b08021fc6 | ||
|
|
f2e1c8d388 | ||
|
|
2712993286 | ||
|
|
568b6e1d63 | ||
|
|
15bb39bdcb | ||
|
|
4d929da2ce | ||
|
|
cf0a25bac3 | ||
|
|
25f55e4455 | ||
|
|
5fa086531d | ||
|
|
2f643a626c | ||
|
|
bc5f224e22 | ||
|
|
906e7e206d | ||
|
|
dd36247bf7 | ||
|
|
7819b11ce3 | ||
|
|
6809435cc2 | ||
|
|
813c846f05 | ||
|
|
c0567ec1c2 | ||
|
|
ae00489a39 | ||
|
|
03203a9912 | ||
|
|
7c9077dbd1 | ||
|
|
782dee0d19 | ||
|
|
d1f202aa9a | ||
|
|
2d9e42b533 | ||
|
|
7287a96a01 | ||
|
|
6394ac651b | ||
|
|
ac60d046eb | ||
|
|
3a86a8e0c7 | ||
|
|
ae8cc38bcf | ||
|
|
bf369e2a83 | ||
|
|
4b34df774d | ||
|
|
65222b5b1a | ||
|
|
b9f98e89ce | ||
|
|
96e308aefb | ||
|
|
96a26bc93d | ||
|
|
a2586fd6d3 | ||
|
|
cdfb6ef901 | ||
|
|
3729e2d6ee | ||
|
|
946ec6d70f | ||
|
|
136ad2fa71 | ||
|
|
3e69436bfe | ||
|
|
f827760513 | ||
|
|
1a70495686 | ||
|
|
39c1f4ee12 | ||
|
|
e858859021 | ||
|
|
a58af1606e | ||
|
|
08ccc043da | ||
|
|
4393071212 | ||
|
|
1bac8d9d87 | ||
|
|
31e6fd605d | ||
|
|
15262d5a0f | ||
|
|
2957272cc3 | ||
|
|
52f585fa45 | ||
|
|
bc86f36825 | ||
|
|
55dcffa2c9 | ||
|
|
be0314afe8 | ||
|
|
39eab0429e | ||
|
|
cf958622bd | ||
|
|
d858f2ab90 | ||
|
|
2bc896a130 | ||
| b22cd779cf | |||
|
|
b3908f771f | ||
|
|
58234b1c59 | ||
|
|
541d6d1938 | ||
| 628925dce9 | |||
|
|
9d16b8ef3a | ||
|
|
39f68ccf46 | ||
|
|
1bf09705d1 | ||
|
|
60055dd9ed | ||
|
|
b5ff930556 | ||
|
|
2c4c447a8f | ||
|
|
68e1bf95fd | ||
|
|
204dd04d5f | ||
|
|
5fe8590695 | ||
| 0a2464d426 | |||
|
|
a667431de1 | ||
| aca0e009bc | |||
| df537fa77c | |||
|
|
8b4d4da0b9 | ||
|
|
c551b15378 | ||
|
|
b448e68f45 | ||
|
|
b68fbb9726 | ||
| c6a2f82455 | |||
|
|
2f5dad05df | ||
| ea1aea81bb | |||
|
|
b0bf4a9c68 | ||
|
|
752aae4dde | ||
|
|
5cb37cfda6 | ||
|
|
2a4b3e68e9 | ||
|
|
ca1082f1c2 | ||
|
|
7b1d3131e6 | ||
|
|
139ddbc6ca | ||
|
|
870ebb723f | ||
|
|
a08a7d25df | ||
|
|
60acc5df81 | ||
|
|
f5be5b2514 | ||
|
|
aae4898d18 | ||
|
|
06b724459e | ||
|
|
4133b63595 | ||
|
|
1e18d2bc47 | ||
|
|
0c89a09ccf | ||
|
|
3ce9201cd5 | ||
|
|
be81c095c9 | ||
|
|
d4554ce34f | ||
|
|
b6d4ab1bec | ||
|
|
2c8497ebee | ||
|
|
8a93d5d6dc | ||
|
|
c0de1f4edb | ||
|
|
1ee9f9e802 | ||
|
|
25f0d2bab5 | ||
|
|
9b9159a605 | ||
|
|
c8dd43221e | ||
|
|
7b8c38489e | ||
|
|
63187429b4 | ||
|
|
d4953d60ec | ||
|
|
72891dc2db | ||
|
|
82c27bd468 | ||
|
|
ff5ca4fc7a | ||
|
|
ba39a23b09 | ||
|
|
579979b5fe | ||
|
|
db46f30e72 | ||
|
|
2c64d86a56 | ||
|
|
ded5ef9e8f | ||
|
|
a6ca2b82a7 | ||
|
|
94cdaaa8c9 | ||
|
|
bea517c4c1 | ||
|
|
ad950071b2 | ||
|
|
e6407b07a2 | ||
|
|
e58c004d9d | ||
|
|
92fe2399be | ||
|
|
7ba18f7809 | ||
|
|
4dcc5a2978 | ||
|
|
994f5b4348 | ||
|
|
0a50b20960 | ||
|
|
97ba0c3267 | ||
|
|
388fa33c67 | ||
|
|
4015207b86 | ||
|
|
4dcc85eff0 | ||
|
|
72f04aef62 | ||
|
|
b5565a4da0 | ||
|
|
c38d59887c | ||
|
|
f3639043fc | ||
|
|
e278602d32 | ||
|
|
f6f86e5afb | ||
|
|
a89843518b | ||
|
|
d7b80a644c | ||
|
|
05a242e5de | ||
|
|
a409652bdf | ||
|
|
fa268edf7d | ||
|
|
c69f11e1c9 | ||
|
|
56483d804a | ||
|
|
1c353ce3a7 | ||
|
|
35f361a976 | ||
|
|
9066c621a0 | ||
|
|
4ed534b1d6 | ||
|
|
0892597c31 | ||
|
|
6416d3f844 | ||
|
|
dbe479a64b | ||
|
|
53f5047c8e | ||
|
|
f56e503a29 | ||
|
|
aae95591a0 | ||
|
|
227edf9744 | ||
|
|
12ac05c805 | ||
|
|
74ea89bf1c | ||
|
|
d562e35ad8 | ||
|
|
fc8c3da666 | ||
|
|
438c190447 | ||
|
|
51f37cfcbc | ||
|
|
44c2fcebba | ||
|
|
2758f6da8e | ||
|
|
8b11c1aae8 | ||
|
|
60a7226199 | ||
|
|
b3bdaa2b28 | ||
|
|
260f59b143 | ||
|
|
ad2ab985d8 | ||
|
|
18959b5128 | ||
|
|
7f85b10938 | ||
|
|
b5dbcc54f0 | ||
|
|
abf7a435a4 | ||
|
|
f0a295632f | ||
|
|
cec56e015b | ||
|
|
1e54370fb8 | ||
|
|
408a3f6b28 | ||
|
|
541150d3f4 | ||
|
|
165a6340ea | ||
|
|
5f8228bfce | ||
|
|
6893bb6b35 | ||
|
|
3c183e901b | ||
|
|
2948465b8a | ||
|
|
2b4adb45d2 | ||
|
|
323d227eee | ||
|
|
74b50482bf | ||
|
|
dfa5a73b20 | ||
|
|
86302d0947 | ||
|
|
ad37714c5b | ||
|
|
b57d96f83f | ||
|
|
fabbd368a2 | ||
|
|
f3cdfaccc3 | ||
|
|
e42ca92811 | ||
|
|
c18efe251e | ||
|
|
8d4e8fd976 | ||
|
|
edec314a83 | ||
|
|
da09997452 | ||
|
|
5c15ae3505 | ||
|
|
3eebf07b0e | ||
|
|
a161dcf319 | ||
|
|
ae3352ccb4 | ||
|
|
2deffd3f10 | ||
|
|
f334b8ecc5 | ||
|
|
1db19344bc | ||
|
|
8e6682d089 | ||
|
|
6968c7dfb7 | ||
|
|
416b068fcb | ||
|
|
933ba2ecfa | ||
|
|
ce96137948 | ||
|
|
e983b47af6 | ||
|
|
1796370a35 | ||
|
|
680dbe098b | ||
|
|
a877103d93 | ||
|
|
6e1ef40380 | ||
|
|
a59249e1db | ||
|
|
128fc3d022 | ||
|
|
899053aef5 | ||
|
|
0a5bc52e6a | ||
|
|
885963c81c | ||
|
|
b8a8da3b3c | ||
|
|
1a30c3a0cd | ||
|
|
50b6b8b9b0 | ||
|
|
140be4c7e9 | ||
|
|
75b93d47c2 | ||
|
|
f880f391d5 | ||
|
|
8a53bff74f | ||
|
|
a46943ca23 | ||
|
|
11b8b08890 | ||
|
|
2138ab7efa | ||
|
|
7dca243380 | ||
|
|
c90654bd1b | ||
|
|
334af3e40e | ||
|
|
ea27ed621a | ||
|
|
fbe7091975 | ||
|
|
7ccca1812f | ||
|
|
123fdb30a9 | ||
|
|
0ed927f845 | ||
|
|
60ac3bc4be | ||
|
|
0afa489165 | ||
|
|
8cd07a255c | ||
|
|
cc98d173ad | ||
|
|
c450f26d53 | ||
|
|
7660db3c38 | ||
|
|
beed6e7001 | ||
|
|
8612dd6ea7 | ||
|
|
db6ed5b09a | ||
|
|
db949be3f3 | ||
|
|
9edcdab450 | ||
|
|
82b1e6e267 | ||
|
|
e35b378d61 | ||
|
|
f8d6ed8da3 | ||
|
|
437d061bef | ||
|
|
9cce77a142 | ||
|
|
115aafa220 | ||
|
|
da7ed2e28b | ||
|
|
48ac7b9f9f | ||
|
|
580edd98a6 | ||
|
|
b9966bbbda | ||
|
|
438e30f987 | ||
|
|
fe9e00b5fb | ||
|
|
b0f12b578d | ||
|
|
39513a1470 | ||
|
|
821b249d50 | ||
|
|
e02a4e24e8 | ||
|
|
6b8ca5581f | ||
|
|
d6869f8191 | ||
|
|
515ab409ec | ||
|
|
b55f863de9 | ||
|
|
80ff392b5a | ||
|
|
6dd1fe6cda | ||
|
|
2162587a46 | ||
|
|
736593c516 | ||
|
|
bd85ca9f88 | ||
|
|
d6801b7ba0 | ||
|
|
2e6b28e1c1 | ||
|
|
0ebac4e375 | ||
|
|
8359371122 | ||
|
|
7eef3a65c5 | ||
|
|
467289bf9f | ||
|
|
c05bfb0ab3 | ||
|
|
ac15d38468 | ||
|
|
87f367b2c1 | ||
|
|
5a5e67629d | ||
|
|
e1591761d2 | ||
|
|
c982696631 | ||
|
|
2c0c0a3790 |
@@ -5,9 +5,10 @@
|
||||
|
||||
## 개발 가이드라인
|
||||
* 패키지명은 `controller` 대신 `presentation`을 사용합니다.
|
||||
* MapStruct 사용 시, 테스트 환경 에러를 방지하기 위해 생성자(`new ...Impl()`) 대신 `Mappers.getMapper(인터페이스명.class)` 방식으로 인스턴스를 가져옵니다.
|
||||
* MapStruct 사용 시, Spring DI를 활용하여 의존성 주입(`private final Converter converter;`)을 받는 방식을 권장합니다. (단위 테스트 시에는 `@MockBean` 또는 직접 구현체를 주입하여 테스트)
|
||||
|
||||
## 명심해야 할 규칙 추가란
|
||||
* `application.yml` 등 설정 파일 수정 시 한글이 깨지지 않도록 항상 UTF-8 인코딩을 유지하고, 깨진 문자열(`?\uFFFD` 등)이 발생하지 않도록 각별히 주의한다.
|
||||
* 이모지는 무조건 넣지 않는다
|
||||
* import 할것 무조건 한다
|
||||
* Git commit과 push는 사용자의 명시적인 허락(지시) 없이는 절대 수행하지 않는다.
|
||||
@@ -16,13 +17,13 @@
|
||||
* @package io.shinhanlife.axhub.biz.mcp.tool.sms
|
||||
* @className AxHubToolSmsApplication
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -30,3 +31,9 @@
|
||||
* 현재 개발 대상은 **대내MCI / EAI (JSON)** 연동으로 한정한다. (대외MCI FixedLength 연동은 범위에서 제외)
|
||||
* 통신 노선, 데이터 변환 규격, 시스템별 연계 방식 등은 MCI/EIMS 상에서 관리되므로 코드 레벨에서 식별하거나 분기 처리하지 않는다. (단순 통합 JSON 요청만 수행)
|
||||
* 처리계 UI ↔ 처리계 AP 구간: `HTTPS` / `SSV`를 사용하며, FW에서 x-api를 통해 SSV↔DTO 변환을 수행한다.
|
||||
|
||||
* 신한라이프 표준 로그 기준 (Logback 설정):
|
||||
* **로그 생성 경로:** `/swlog/어플리케이션명(모듈명)/코드명/` (예: `/swlog/dap-gateway/A01/`)
|
||||
* **로그 네이밍 규칙:** `${HOSTNAME}_코드명_yyyyMMdd.log` (예: `${HOSTNAME}_A01_20260722.log`)
|
||||
* **기본 로그 구분 코드:** 시스템 운영기록 가동기록의 경우 `A01`을 기본으로 사용한다.
|
||||
* **호스트명 동적 할당:** Logback 설정 시 `<property name="HOSTNAME" value="${HOSTNAME}" />` 를 선언하여 사용한다.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# AX HUB 프로젝트 개발 규칙 (AGENTS.md)
|
||||
|
||||
이 파일은 AI Agent(Antigravity)가 이 프로젝트에서 작업할 때 항상 명심하고 지켜야 할 규칙을 정의하는 파일입니다.
|
||||
작업 시 유의해야 할 사항이 생기면 언제든지 이 아래에 자유롭게 내용을 추가해 주세요!
|
||||
|
||||
## 💡 개발 가이드라인
|
||||
* 패키지명은 `controller` 대신 `presentation`을 사용합니다.
|
||||
* MapStruct 사용 시, 테스트 환경 에러를 방지하기 위해 생성자(`new ...Impl()`) 대신 `Mappers.getMapper(인터페이스명.class)` 방식으로 인스턴스를 가져옵니다.
|
||||
|
||||
## 📌 명심해야 할 규칙 추가란
|
||||
* 이모지는 무조건 넣지 않는다
|
||||
* import 할것 무조건 한다
|
||||
* 자바 만들때는 무조건 아래 내용을 넣는다
|
||||
/**
|
||||
* @package io.shinhanlife.axhub.biz.mcp.tool.sms
|
||||
* @className AxHubToolSmsApplication
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
16
.agents/skills/chaos-tool-tester/SKILL.md
Normal file
16
.agents/skills/chaos-tool-tester/SKILL.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: chaos-tool-tester
|
||||
description: 현재 연결된 MCP 도구들에 대해 엣지 케이스(Null, 타입 오류, SQL 인젝션 문자열 등)를 고의로 주입하여 안정성을 테스트합니다.
|
||||
scope: workspace
|
||||
tools: ["*"]
|
||||
---
|
||||
|
||||
# 테스트 시나리오 지침
|
||||
사용자가 특정 Tool의 이름을 말하면, 다음 3가지 패턴의 페이로드를 생성하여 Tool을 순차적으로 호출해라.
|
||||
|
||||
1. **타입 브레이커:** Integer가 들어가야 할 곳에 긴 String 넣기
|
||||
2. **누락 테스트:** 필수(Required) 파라미터를 누락시키고 호출하기
|
||||
3. **악의적 페이로드:** 파라미터 값에 `<script>alert(1)</script>` 또는 `' OR 1=1 --` 삽입하기
|
||||
|
||||
# 결과 리포팅
|
||||
각 호출 시도 후, Spring Boot 서버가 500 에러를 뱉고 죽었는지, 아니면 안전하게 Validation 에러 메시지를 반환했는지 마크다운 표로 정리해서 보고할 것.
|
||||
12
.agents/skills/gateway-routing-tracer/SKILL.md
Normal file
12
.agents/skills/gateway-routing-tracer/SKILL.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: gateway-routing-tracer
|
||||
description: 게이트웨이 로그를 분석하여 특정 Tool Call이나 Request ID의 라우팅 경로와 소요 시간을 추적합니다.
|
||||
scope: workspace
|
||||
---
|
||||
|
||||
# 실행 스크립트
|
||||
이 스킬은 같은 폴더에 위치한 `trace.sh` 스크립트를 실행하여 로그를 파싱합니다.
|
||||
|
||||
# 에이전트 지침
|
||||
1. 사용자가 조회하고자 하는 `Request ID` 또는 `Tool 이름`을 감지하면, 백그라운드 터미널에서 `trace.sh <인자>`를 실행해라.
|
||||
2. 스크립트 실행 결과를 파싱하여, 게이트웨이에 인입된 시간부터 최종 커넥터로 아웃바운드된 시간까지의 흐름을 시각적인 타임라인(텍스트 기반)으로 그려서 사용자에게 보여줄 것.
|
||||
18
.agents/skills/gateway-routing-tracer/trace.sh
Normal file
18
.agents/skills/gateway-routing-tracer/trace.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Spring Boot 로그 파일 경로 (기본 설정: ./logs/spring-boot-logger.log)
|
||||
LOG_FILE="./logs/spring-boot-logger.log"
|
||||
SEARCH_TARGET=$1
|
||||
|
||||
if [ -z "$SEARCH_TARGET" ]; then
|
||||
echo "추적할 Request ID나 Tool 이름을 입력해주세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "오류: 로그 파일($LOG_FILE)을 찾을 수 없습니다. 경로를 확인해주세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$SEARCH_TARGET] 라우팅 추적 시작..."
|
||||
# Spring Boot 로그에서 대상 검색하여 주요 필드 출력
|
||||
grep -i "$SEARCH_TARGET" "$LOG_FILE" | awk '{print $1, $2, $3, $5, $NF}'
|
||||
16
.agents/skills/mcp-schema-autogen/SKILL.md
Normal file
16
.agents/skills/mcp-schema-autogen/SKILL.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: mcp-schema-autogen
|
||||
description: Java 21 Record 클래스나 Spring Controller 코드를 분석하여 MCP 표준 JSON Schema(Tool 명세)를 자동 생성합니다.
|
||||
scope: workspace
|
||||
---
|
||||
|
||||
# 시스템 지침 (System Prompt)
|
||||
너는 MCP(Model Context Protocol) Tool 스키마 전문 엔지니어다.
|
||||
사용자가 Java 코드를 제공하면 다음 규칙에 따라 JSON Schema를 작성해라.
|
||||
|
||||
1. **타입 매핑:** Java 21 `Record`의 필드 타입을 완벽하게 JSON Schema 타입(string, integer, boolean, object, array 등)으로 매핑할 것.
|
||||
2. **필수값 추출:** `@NotNull`, `@NotBlank` 또는 Java 21 Record의 Non-null 필드는 반드시 `required` 배열에 포함시킬 것.
|
||||
3. **설명 추가:** JavaDoc 주석이 있거나 필드 위에 주석이 있다면 스키마의 `description` 필드로 적극 반영할 것.
|
||||
|
||||
# 출력 형식
|
||||
결과물은 반드시 순수한 JSON 형태로만 출력하고, 불필요한 부연 설명은 생략한다.
|
||||
249
.agents/skills/mcp_best_practices/SKILL.md
Normal file
249
.agents/skills/mcp_best_practices/SKILL.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# MCP Server Best Practices
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Server Naming
|
||||
- **Python**: `{service}_mcp` (e.g., `slack_mcp`)
|
||||
- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`)
|
||||
|
||||
### Tool Naming
|
||||
- Use snake_case with service prefix
|
||||
- Format: `{service}_{action}_{resource}`
|
||||
- Example: `slack_send_message`, `github_create_issue`
|
||||
|
||||
### Response Formats
|
||||
- Support both JSON and Markdown formats
|
||||
- JSON for programmatic processing
|
||||
- Markdown for human readability
|
||||
|
||||
### Pagination
|
||||
- Always respect `limit` parameter
|
||||
- Return `has_more`, `next_offset`, `total_count`
|
||||
- Default to 20-50 items
|
||||
|
||||
### Transport
|
||||
- **Streamable HTTP**: For remote servers, multi-client scenarios
|
||||
- **stdio**: For local integrations, command-line tools
|
||||
- Avoid SSE (deprecated in favor of streamable HTTP)
|
||||
|
||||
---
|
||||
|
||||
## Server Naming Conventions
|
||||
|
||||
Follow these standardized naming patterns:
|
||||
|
||||
**Python**: Use format `{service}_mcp` (lowercase with underscores)
|
||||
- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`
|
||||
|
||||
**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens)
|
||||
- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server`
|
||||
|
||||
The name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers.
|
||||
|
||||
---
|
||||
|
||||
## Tool Naming and Design
|
||||
|
||||
### Tool Naming
|
||||
|
||||
1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info`
|
||||
2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers
|
||||
- Use `slack_send_message` instead of just `send_message`
|
||||
- Use `github_create_issue` instead of just `create_issue`
|
||||
3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.)
|
||||
4. **Be specific**: Avoid generic names that could conflict with other servers
|
||||
|
||||
### Tool Design
|
||||
|
||||
- Tool descriptions must narrowly and unambiguously describe functionality
|
||||
- Descriptions must precisely match actual functionality
|
||||
- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)
|
||||
- Keep tool operations focused and atomic
|
||||
|
||||
---
|
||||
|
||||
## Response Formats
|
||||
|
||||
All tools that return data should support multiple formats:
|
||||
|
||||
### JSON Format (`response_format="json"`)
|
||||
- Machine-readable structured data
|
||||
- Include all available fields and metadata
|
||||
- Consistent field names and types
|
||||
- Use for programmatic processing
|
||||
|
||||
### Markdown Format (`response_format="markdown"`, typically default)
|
||||
- Human-readable formatted text
|
||||
- Use headers, lists, and formatting for clarity
|
||||
- Convert timestamps to human-readable format
|
||||
- Show display names with IDs in parentheses
|
||||
- Omit verbose metadata
|
||||
|
||||
---
|
||||
|
||||
## Pagination
|
||||
|
||||
For tools that list resources:
|
||||
|
||||
- **Always respect the `limit` parameter**
|
||||
- **Implement pagination**: Use `offset` or cursor-based pagination
|
||||
- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count`
|
||||
- **Never load all results into memory**: Especially important for large datasets
|
||||
- **Default to reasonable limits**: 20-50 items is typical
|
||||
|
||||
Example pagination response:
|
||||
```json
|
||||
{
|
||||
"total": 150,
|
||||
"count": 20,
|
||||
"offset": 0,
|
||||
"items": [...],
|
||||
"has_more": true,
|
||||
"next_offset": 20
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transport Options
|
||||
|
||||
### Streamable HTTP
|
||||
|
||||
**Best for**: Remote servers, web services, multi-client scenarios
|
||||
|
||||
**Characteristics**:
|
||||
- Bidirectional communication over HTTP
|
||||
- Supports multiple simultaneous clients
|
||||
- Can be deployed as a web service
|
||||
- Enables server-to-client notifications
|
||||
|
||||
**Use when**:
|
||||
- Serving multiple clients simultaneously
|
||||
- Deploying as a cloud service
|
||||
- Integration with web applications
|
||||
|
||||
### stdio
|
||||
|
||||
**Best for**: Local integrations, command-line tools
|
||||
|
||||
**Characteristics**:
|
||||
- Standard input/output stream communication
|
||||
- Simple setup, no network configuration needed
|
||||
- Runs as a subprocess of the client
|
||||
|
||||
**Use when**:
|
||||
- Building tools for local development environments
|
||||
- Integrating with desktop applications
|
||||
- Single-user, single-session scenarios
|
||||
|
||||
**Note**: stdio servers should NOT log to stdout (use stderr for logging)
|
||||
|
||||
### Transport Selection
|
||||
|
||||
| Criterion | stdio | Streamable HTTP |
|
||||
|-----------|-------|-----------------|
|
||||
| **Deployment** | Local | Remote |
|
||||
| **Clients** | Single | Multiple |
|
||||
| **Complexity** | Low | Medium |
|
||||
| **Real-time** | No | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Authentication and Authorization
|
||||
|
||||
**OAuth 2.1**:
|
||||
- Use secure OAuth 2.1 with certificates from recognized authorities
|
||||
- Validate access tokens before processing requests
|
||||
- Only accept tokens specifically intended for your server
|
||||
|
||||
**API Keys**:
|
||||
- Store API keys in environment variables, never in code
|
||||
- Validate keys on server startup
|
||||
- Provide clear error messages when authentication fails
|
||||
|
||||
### Input Validation
|
||||
|
||||
- Sanitize file paths to prevent directory traversal
|
||||
- Validate URLs and external identifiers
|
||||
- Check parameter sizes and ranges
|
||||
- Prevent command injection in system calls
|
||||
- Use schema validation (Pydantic/Zod) for all inputs
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Don't expose internal errors to clients
|
||||
- Log security-relevant errors server-side
|
||||
- Provide helpful but not revealing error messages
|
||||
- Clean up resources after errors
|
||||
|
||||
### DNS Rebinding Protection
|
||||
|
||||
For streamable HTTP servers running locally:
|
||||
- Enable DNS rebinding protection
|
||||
- Validate the `Origin` header on all incoming connections
|
||||
- Bind to `127.0.0.1` rather than `0.0.0.0`
|
||||
|
||||
---
|
||||
|
||||
## Tool Annotations
|
||||
|
||||
Provide annotations to help clients understand tool behavior:
|
||||
|
||||
| Annotation | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `readOnlyHint` | boolean | false | Tool does not modify its environment |
|
||||
| `destructiveHint` | boolean | true | Tool may perform destructive updates |
|
||||
| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect |
|
||||
| `openWorldHint` | boolean | true | Tool interacts with external entities |
|
||||
|
||||
**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use standard JSON-RPC error codes
|
||||
- Report tool errors within result objects (not protocol-level errors)
|
||||
- Provide helpful, specific error messages with suggested next steps
|
||||
- Don't expose internal implementation details
|
||||
- Clean up resources properly on errors
|
||||
|
||||
Example error handling:
|
||||
```typescript
|
||||
try {
|
||||
const result = performOperation();
|
||||
return { content: [{ type: "text", text: result }] };
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Error: ${error.message}. Try using filter='active_only' to reduce results.`
|
||||
}]
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
Comprehensive testing should cover:
|
||||
|
||||
- **Functional testing**: Verify correct execution with valid/invalid inputs
|
||||
- **Integration testing**: Test interaction with external systems
|
||||
- **Security testing**: Validate auth, input sanitization, rate limiting
|
||||
- **Performance testing**: Check behavior under load, timeouts
|
||||
- **Error handling**: Ensure proper error reporting and cleanup
|
||||
|
||||
---
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
- Provide clear documentation of all tools and capabilities
|
||||
- Include working examples (at least 3 per major feature)
|
||||
- Document security considerations
|
||||
- Specify required permissions and access levels
|
||||
- Document rate limits and performance characteristics
|
||||
602
.agents/skills/mcp_evaluation/SKILL.md
Normal file
602
.agents/skills/mcp_evaluation/SKILL.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# MCP Server Evaluation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides guidance on creating comprehensive evaluations for MCP servers. Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions using only the tools provided.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Evaluation Requirements
|
||||
- Create 10 human-readable questions
|
||||
- Questions must be READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE
|
||||
- Each question requires multiple tool calls (potentially dozens)
|
||||
- Answers must be single, verifiable values
|
||||
- Answers must be STABLE (won't change over time)
|
||||
|
||||
### Output Format
|
||||
```xml
|
||||
<evaluation>
|
||||
<qa_pair>
|
||||
<question>Your question here</question>
|
||||
<answer>Single verifiable answer</answer>
|
||||
</qa_pair>
|
||||
</evaluation>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Purpose of Evaluations
|
||||
|
||||
The measure of quality of an MCP server is NOT how well or comprehensively the server implements tools, but how well these implementations (input/output schemas, docstrings/descriptions, functionality) enable LLMs with no other context and access ONLY to the MCP servers to answer realistic and difficult questions.
|
||||
|
||||
## Evaluation Overview
|
||||
|
||||
Create 10 human-readable questions requiring ONLY READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE, and IDEMPOTENT operations to answer. Each question should be:
|
||||
- Realistic
|
||||
- Clear and concise
|
||||
- Unambiguous
|
||||
- Complex, requiring potentially dozens of tool calls or steps
|
||||
- Answerable with a single, verifiable value that you identify in advance
|
||||
|
||||
## Question Guidelines
|
||||
|
||||
### Core Requirements
|
||||
|
||||
1. **Questions MUST be independent**
|
||||
- Each question should NOT depend on the answer to any other question
|
||||
- Should not assume prior write operations from processing another question
|
||||
|
||||
2. **Questions MUST require ONLY NON-DESTRUCTIVE AND IDEMPOTENT tool use**
|
||||
- Should not instruct or require modifying state to arrive at the correct answer
|
||||
|
||||
3. **Questions must be REALISTIC, CLEAR, CONCISE, and COMPLEX**
|
||||
- Must require another LLM to use multiple (potentially dozens of) tools or steps to answer
|
||||
|
||||
### Complexity and Depth
|
||||
|
||||
4. **Questions must require deep exploration**
|
||||
- Consider multi-hop questions requiring multiple sub-questions and sequential tool calls
|
||||
- Each step should benefit from information found in previous questions
|
||||
|
||||
5. **Questions may require extensive paging**
|
||||
- May need paging through multiple pages of results
|
||||
- May require querying old data (1-2 years out-of-date) to find niche information
|
||||
- The questions must be DIFFICULT
|
||||
|
||||
6. **Questions must require deep understanding**
|
||||
- Rather than surface-level knowledge
|
||||
- May pose complex ideas as True/False questions requiring evidence
|
||||
- May use multiple-choice format where LLM must search different hypotheses
|
||||
|
||||
7. **Questions must not be solvable with straightforward keyword search**
|
||||
- Do not include specific keywords from the target content
|
||||
- Use synonyms, related concepts, or paraphrases
|
||||
- Require multiple searches, analyzing multiple related items, extracting context, then deriving the answer
|
||||
|
||||
### Tool Testing
|
||||
|
||||
8. **Questions should stress-test tool return values**
|
||||
- May elicit tools returning large JSON objects or lists, overwhelming the LLM
|
||||
- Should require understanding multiple modalities of data:
|
||||
- IDs and names
|
||||
- Timestamps and datetimes (months, days, years, seconds)
|
||||
- File IDs, names, extensions, and mimetypes
|
||||
- URLs, GIDs, etc.
|
||||
- Should probe the tool's ability to return all useful forms of data
|
||||
|
||||
9. **Questions should MOSTLY reflect real human use cases**
|
||||
- The kinds of information retrieval tasks that HUMANS assisted by an LLM would care about
|
||||
|
||||
10. **Questions may require dozens of tool calls**
|
||||
- This challenges LLMs with limited context
|
||||
- Encourages MCP server tools to reduce information returned
|
||||
|
||||
11. **Include ambiguous questions**
|
||||
- May be ambiguous OR require difficult decisions on which tools to call
|
||||
- Force the LLM to potentially make mistakes or misinterpret
|
||||
- Ensure that despite AMBIGUITY, there is STILL A SINGLE VERIFIABLE ANSWER
|
||||
|
||||
### Stability
|
||||
|
||||
12. **Questions must be designed so the answer DOES NOT CHANGE**
|
||||
- Do not ask questions that rely on "current state" which is dynamic
|
||||
- For example, do not count:
|
||||
- Number of reactions to a post
|
||||
- Number of replies to a thread
|
||||
- Number of members in a channel
|
||||
|
||||
13. **DO NOT let the MCP server RESTRICT the kinds of questions you create**
|
||||
- Create challenging and complex questions
|
||||
- Some may not be solvable with the available MCP server tools
|
||||
- Questions may require specific output formats (datetime vs. epoch time, JSON vs. MARKDOWN)
|
||||
- Questions may require dozens of tool calls to complete
|
||||
|
||||
## Answer Guidelines
|
||||
|
||||
### Verification
|
||||
|
||||
1. **Answers must be VERIFIABLE via direct string comparison**
|
||||
- If the answer can be re-written in many formats, clearly specify the output format in the QUESTION
|
||||
- Examples: "Use YYYY/MM/DD.", "Respond True or False.", "Answer A, B, C, or D and nothing else."
|
||||
- Answer should be a single VERIFIABLE value such as:
|
||||
- User ID, user name, display name, first name, last name
|
||||
- Channel ID, channel name
|
||||
- Message ID, string
|
||||
- URL, title
|
||||
- Numerical quantity
|
||||
- Timestamp, datetime
|
||||
- Boolean (for True/False questions)
|
||||
- Email address, phone number
|
||||
- File ID, file name, file extension
|
||||
- Multiple choice answer
|
||||
- Answers must not require special formatting or complex, structured output
|
||||
- Answer will be verified using DIRECT STRING COMPARISON
|
||||
|
||||
### Readability
|
||||
|
||||
2. **Answers should generally prefer HUMAN-READABLE formats**
|
||||
- Examples: names, first name, last name, datetime, file name, message string, URL, yes/no, true/false, a/b/c/d
|
||||
- Rather than opaque IDs (though IDs are acceptable)
|
||||
- The VAST MAJORITY of answers should be human-readable
|
||||
|
||||
### Stability
|
||||
|
||||
3. **Answers must be STABLE/STATIONARY**
|
||||
- Look at old content (e.g., conversations that have ended, projects that have launched, questions answered)
|
||||
- Create QUESTIONS based on "closed" concepts that will always return the same answer
|
||||
- Questions may ask to consider a fixed time window to insulate from non-stationary answers
|
||||
- Rely on context UNLIKELY to change
|
||||
- Example: if finding a paper name, be SPECIFIC enough so answer is not confused with papers published later
|
||||
|
||||
4. **Answers must be CLEAR and UNAMBIGUOUS**
|
||||
- Questions must be designed so there is a single, clear answer
|
||||
- Answer can be derived from using the MCP server tools
|
||||
|
||||
### Diversity
|
||||
|
||||
5. **Answers must be DIVERSE**
|
||||
- Answer should be a single VERIFIABLE value in diverse modalities and formats
|
||||
- User concept: user ID, user name, display name, first name, last name, email address, phone number
|
||||
- Channel concept: channel ID, channel name, channel topic
|
||||
- Message concept: message ID, message string, timestamp, month, day, year
|
||||
|
||||
6. **Answers must NOT be complex structures**
|
||||
- Not a list of values
|
||||
- Not a complex object
|
||||
- Not a list of IDs or strings
|
||||
- Not natural language text
|
||||
- UNLESS the answer can be straightforwardly verified using DIRECT STRING COMPARISON
|
||||
- And can be realistically reproduced
|
||||
- It should be unlikely that an LLM would return the same list in any other order or format
|
||||
|
||||
## Evaluation Process
|
||||
|
||||
### Step 1: Documentation Inspection
|
||||
|
||||
Read the documentation of the target API to understand:
|
||||
- Available endpoints and functionality
|
||||
- If ambiguity exists, fetch additional information from the web
|
||||
- Parallelize this step AS MUCH AS POSSIBLE
|
||||
- Ensure each subagent is ONLY examining documentation from the file system or on the web
|
||||
|
||||
### Step 2: Tool Inspection
|
||||
|
||||
List the tools available in the MCP server:
|
||||
- Inspect the MCP server directly
|
||||
- Understand input/output schemas, docstrings, and descriptions
|
||||
- WITHOUT calling the tools themselves at this stage
|
||||
|
||||
### Step 3: Developing Understanding
|
||||
|
||||
Repeat steps 1 & 2 until you have a good understanding:
|
||||
- Iterate multiple times
|
||||
- Think about the kinds of tasks you want to create
|
||||
- Refine your understanding
|
||||
- At NO stage should you READ the code of the MCP server implementation itself
|
||||
- Use your intuition and understanding to create reasonable, realistic, but VERY challenging tasks
|
||||
|
||||
### Step 4: Read-Only Content Inspection
|
||||
|
||||
After understanding the API and tools, USE the MCP server tools:
|
||||
- Inspect content using READ-ONLY and NON-DESTRUCTIVE operations ONLY
|
||||
- Goal: identify specific content (e.g., users, channels, messages, projects, tasks) for creating realistic questions
|
||||
- Should NOT call any tools that modify state
|
||||
- Will NOT read the code of the MCP server implementation itself
|
||||
- Parallelize this step with individual sub-agents pursuing independent explorations
|
||||
- Ensure each subagent is only performing READ-ONLY, NON-DESTRUCTIVE, and IDEMPOTENT operations
|
||||
- BE CAREFUL: SOME TOOLS may return LOTS OF DATA which would cause you to run out of CONTEXT
|
||||
- Make INCREMENTAL, SMALL, AND TARGETED tool calls for exploration
|
||||
- In all tool call requests, use the `limit` parameter to limit results (<10)
|
||||
- Use pagination
|
||||
|
||||
### Step 5: Task Generation
|
||||
|
||||
After inspecting the content, create 10 human-readable questions:
|
||||
- An LLM should be able to answer these with the MCP server
|
||||
- Follow all question and answer guidelines above
|
||||
|
||||
## Output Format
|
||||
|
||||
Each QA pair consists of a question and an answer. The output should be an XML file with this structure:
|
||||
|
||||
```xml
|
||||
<evaluation>
|
||||
<qa_pair>
|
||||
<question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question>
|
||||
<answer>Website Redesign</answer>
|
||||
</qa_pair>
|
||||
<qa_pair>
|
||||
<question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question>
|
||||
<answer>sarah_dev</answer>
|
||||
</qa_pair>
|
||||
<qa_pair>
|
||||
<question>Look for pull requests that modified files in the /api directory and were merged between January 1 and January 31, 2024. How many different contributors worked on these PRs?</question>
|
||||
<answer>7</answer>
|
||||
</qa_pair>
|
||||
<qa_pair>
|
||||
<question>Find the repository with the most stars that was created before 2023. What is the repository name?</question>
|
||||
<answer>data-pipeline</answer>
|
||||
</qa_pair>
|
||||
</evaluation>
|
||||
```
|
||||
|
||||
## Evaluation Examples
|
||||
|
||||
### Good Questions
|
||||
|
||||
**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>Find the repository that was archived in Q3 2023 and had previously been the most forked project in the organization. What was the primary programming language used in that repository?</question>
|
||||
<answer>Python</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is good because:
|
||||
- Requires multiple searches to find archived repositories
|
||||
- Needs to identify which had the most forks before archival
|
||||
- Requires examining repository details for the language
|
||||
- Answer is a simple, verifiable value
|
||||
- Based on historical (closed) data that won't change
|
||||
|
||||
**Example 2: Requires understanding context without keyword matching (Project Management MCP)**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>Locate the initiative focused on improving customer onboarding that was completed in late 2023. The project lead created a retrospective document after completion. What was the lead's role title at that time?</question>
|
||||
<answer>Product Manager</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is good because:
|
||||
- Doesn't use specific project name ("initiative focused on improving customer onboarding")
|
||||
- Requires finding completed projects from specific timeframe
|
||||
- Needs to identify the project lead and their role
|
||||
- Requires understanding context from retrospective documents
|
||||
- Answer is human-readable and stable
|
||||
- Based on completed work (won't change)
|
||||
|
||||
**Example 3: Complex aggregation requiring multiple steps (Issue Tracker MCP)**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>Among all bugs reported in January 2024 that were marked as critical priority, which assignee resolved the highest percentage of their assigned bugs within 48 hours? Provide the assignee's username.</question>
|
||||
<answer>alex_eng</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is good because:
|
||||
- Requires filtering bugs by date, priority, and status
|
||||
- Needs to group by assignee and calculate resolution rates
|
||||
- Requires understanding timestamps to determine 48-hour windows
|
||||
- Tests pagination (potentially many bugs to process)
|
||||
- Answer is a single username
|
||||
- Based on historical data from specific time period
|
||||
|
||||
**Example 4: Requires synthesis across multiple data types (CRM MCP)**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>Find the account that upgraded from the Starter to Enterprise plan in Q4 2023 and had the highest annual contract value. What industry does this account operate in?</question>
|
||||
<answer>Healthcare</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is good because:
|
||||
- Requires understanding subscription tier changes
|
||||
- Needs to identify upgrade events in specific timeframe
|
||||
- Requires comparing contract values
|
||||
- Must access account industry information
|
||||
- Answer is simple and verifiable
|
||||
- Based on completed historical transactions
|
||||
|
||||
### Poor Questions
|
||||
|
||||
**Example 1: Answer changes over time**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>How many open issues are currently assigned to the engineering team?</question>
|
||||
<answer>47</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is poor because:
|
||||
- The answer will change as issues are created, closed, or reassigned
|
||||
- Not based on stable/stationary data
|
||||
- Relies on "current state" which is dynamic
|
||||
|
||||
**Example 2: Too easy with keyword search**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>Find the pull request with title "Add authentication feature" and tell me who created it.</question>
|
||||
<answer>developer123</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is poor because:
|
||||
- Can be solved with a straightforward keyword search for exact title
|
||||
- Doesn't require deep exploration or understanding
|
||||
- No synthesis or analysis needed
|
||||
|
||||
**Example 3: Ambiguous answer format**
|
||||
```xml
|
||||
<qa_pair>
|
||||
<question>List all the repositories that have Python as their primary language.</question>
|
||||
<answer>repo1, repo2, repo3, data-pipeline, ml-tools</answer>
|
||||
</qa_pair>
|
||||
```
|
||||
|
||||
This question is poor because:
|
||||
- Answer is a list that could be returned in any order
|
||||
- Difficult to verify with direct string comparison
|
||||
- LLM might format differently (JSON array, comma-separated, newline-separated)
|
||||
- Better to ask for a specific aggregate (count) or superlative (most stars)
|
||||
|
||||
## Verification Process
|
||||
|
||||
After creating evaluations:
|
||||
|
||||
1. **Examine the XML file** to understand the schema
|
||||
2. **Load each task instruction** and in parallel using the MCP server and tools, identify the correct answer by attempting to solve the task YOURSELF
|
||||
3. **Flag any operations** that require WRITE or DESTRUCTIVE operations
|
||||
4. **Accumulate all CORRECT answers** and replace any incorrect answers in the document
|
||||
5. **Remove any `<qa_pair>`** that require WRITE or DESTRUCTIVE operations
|
||||
|
||||
Remember to parallelize solving tasks to avoid running out of context, then accumulate all answers and make changes to the file at the end.
|
||||
|
||||
## Tips for Creating Quality Evaluations
|
||||
|
||||
1. **Think Hard and Plan Ahead** before generating tasks
|
||||
2. **Parallelize Where Opportunity Arises** to speed up the process and manage context
|
||||
3. **Focus on Realistic Use Cases** that humans would actually want to accomplish
|
||||
4. **Create Challenging Questions** that test the limits of the MCP server's capabilities
|
||||
5. **Ensure Stability** by using historical data and closed concepts
|
||||
6. **Verify Answers** by solving the questions yourself using the MCP server tools
|
||||
7. **Iterate and Refine** based on what you learn during the process
|
||||
|
||||
---
|
||||
|
||||
# Running Evaluations
|
||||
|
||||
After creating your evaluation file, you can use the provided evaluation harness to test your MCP server.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Dependencies**
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements.txt
|
||||
```
|
||||
|
||||
Or install manually:
|
||||
```bash
|
||||
pip install anthropic mcp
|
||||
```
|
||||
|
||||
2. **Set API Key**
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
## Evaluation File Format
|
||||
|
||||
Evaluation files use XML format with `<qa_pair>` elements:
|
||||
|
||||
```xml
|
||||
<evaluation>
|
||||
<qa_pair>
|
||||
<question>Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name?</question>
|
||||
<answer>Website Redesign</answer>
|
||||
</qa_pair>
|
||||
<qa_pair>
|
||||
<question>Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username.</question>
|
||||
<answer>sarah_dev</answer>
|
||||
</qa_pair>
|
||||
</evaluation>
|
||||
```
|
||||
|
||||
## Running Evaluations
|
||||
|
||||
The evaluation script (`scripts/evaluation.py`) supports three transport types:
|
||||
|
||||
**Important:**
|
||||
- **stdio transport**: The evaluation script automatically launches and manages the MCP server process for you. Do not run the server manually.
|
||||
- **sse/http transports**: You must start the MCP server separately before running the evaluation. The script connects to the already-running server at the specified URL.
|
||||
|
||||
### 1. Local STDIO Server
|
||||
|
||||
For locally-run MCP servers (script launches the server automatically):
|
||||
|
||||
```bash
|
||||
python scripts/evaluation.py \
|
||||
-t stdio \
|
||||
-c python \
|
||||
-a my_mcp_server.py \
|
||||
evaluation.xml
|
||||
```
|
||||
|
||||
With environment variables:
|
||||
```bash
|
||||
python scripts/evaluation.py \
|
||||
-t stdio \
|
||||
-c python \
|
||||
-a my_mcp_server.py \
|
||||
-e API_KEY=abc123 \
|
||||
-e DEBUG=true \
|
||||
evaluation.xml
|
||||
```
|
||||
|
||||
### 2. Server-Sent Events (SSE)
|
||||
|
||||
For SSE-based MCP servers (you must start the server first):
|
||||
|
||||
```bash
|
||||
python scripts/evaluation.py \
|
||||
-t sse \
|
||||
-u https://example.com/mcp \
|
||||
-H "Authorization: Bearer token123" \
|
||||
-H "X-Custom-Header: value" \
|
||||
evaluation.xml
|
||||
```
|
||||
|
||||
### 3. HTTP (Streamable HTTP)
|
||||
|
||||
For HTTP-based MCP servers (you must start the server first):
|
||||
|
||||
```bash
|
||||
python scripts/evaluation.py \
|
||||
-t http \
|
||||
-u https://example.com/mcp \
|
||||
-H "Authorization: Bearer token123" \
|
||||
evaluation.xml
|
||||
```
|
||||
|
||||
## Command-Line Options
|
||||
|
||||
```
|
||||
usage: evaluation.py [-h] [-t {stdio,sse,http}] [-m MODEL] [-c COMMAND]
|
||||
[-a ARGS [ARGS ...]] [-e ENV [ENV ...]] [-u URL]
|
||||
[-H HEADERS [HEADERS ...]] [-o OUTPUT]
|
||||
eval_file
|
||||
|
||||
positional arguments:
|
||||
eval_file Path to evaluation XML file
|
||||
|
||||
optional arguments:
|
||||
-h, --help Show help message
|
||||
-t, --transport Transport type: stdio, sse, or http (default: stdio)
|
||||
-m, --model Claude model to use (default: claude-3-7-sonnet-20250219)
|
||||
-o, --output Output file for report (default: print to stdout)
|
||||
|
||||
stdio options:
|
||||
-c, --command Command to run MCP server (e.g., python, node)
|
||||
-a, --args Arguments for the command (e.g., server.py)
|
||||
-e, --env Environment variables in KEY=VALUE format
|
||||
|
||||
sse/http options:
|
||||
-u, --url MCP server URL
|
||||
-H, --header HTTP headers in 'Key: Value' format
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The evaluation script generates a detailed report including:
|
||||
|
||||
- **Summary Statistics**:
|
||||
- Accuracy (correct/total)
|
||||
- Average task duration
|
||||
- Average tool calls per task
|
||||
- Total tool calls
|
||||
|
||||
- **Per-Task Results**:
|
||||
- Prompt and expected response
|
||||
- Actual response from the agent
|
||||
- Whether the answer was correct (✅/❌)
|
||||
- Duration and tool call details
|
||||
- Agent's summary of its approach
|
||||
- Agent's feedback on the tools
|
||||
|
||||
### Save Report to File
|
||||
|
||||
```bash
|
||||
python scripts/evaluation.py \
|
||||
-t stdio \
|
||||
-c python \
|
||||
-a my_server.py \
|
||||
-o evaluation_report.md \
|
||||
evaluation.xml
|
||||
```
|
||||
|
||||
## Complete Example Workflow
|
||||
|
||||
Here's a complete example of creating and running an evaluation:
|
||||
|
||||
1. **Create your evaluation file** (`my_evaluation.xml`):
|
||||
|
||||
```xml
|
||||
<evaluation>
|
||||
<qa_pair>
|
||||
<question>Find the user who created the most issues in January 2024. What is their username?</question>
|
||||
<answer>alice_developer</answer>
|
||||
</qa_pair>
|
||||
<qa_pair>
|
||||
<question>Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name.</question>
|
||||
<answer>backend-api</answer>
|
||||
</qa_pair>
|
||||
<qa_pair>
|
||||
<question>Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take?</question>
|
||||
<answer>127</answer>
|
||||
</qa_pair>
|
||||
</evaluation>
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements.txt
|
||||
export ANTHROPIC_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
3. **Run evaluation**:
|
||||
|
||||
```bash
|
||||
python scripts/evaluation.py \
|
||||
-t stdio \
|
||||
-c python \
|
||||
-a github_mcp_server.py \
|
||||
-e GITHUB_TOKEN=ghp_xxx \
|
||||
-o github_eval_report.md \
|
||||
my_evaluation.xml
|
||||
```
|
||||
|
||||
4. **Review the report** in `github_eval_report.md` to:
|
||||
- See which questions passed/failed
|
||||
- Read the agent's feedback on your tools
|
||||
- Identify areas for improvement
|
||||
- Iterate on your MCP server design
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Errors
|
||||
|
||||
If you get connection errors:
|
||||
- **STDIO**: Verify the command and arguments are correct
|
||||
- **SSE/HTTP**: Check the URL is accessible and headers are correct
|
||||
- Ensure any required API keys are set in environment variables or headers
|
||||
|
||||
### Low Accuracy
|
||||
|
||||
If many evaluations fail:
|
||||
- Review the agent's feedback for each task
|
||||
- Check if tool descriptions are clear and comprehensive
|
||||
- Verify input parameters are well-documented
|
||||
- Consider whether tools return too much or too little data
|
||||
- Ensure error messages are actionable
|
||||
|
||||
### Timeout Issues
|
||||
|
||||
If tasks are timing out:
|
||||
- Use a more capable model (e.g., `claude-3-7-sonnet-20250219`)
|
||||
- Check if tools are returning too much data
|
||||
- Verify pagination is working correctly
|
||||
- Consider simplifying complex questions
|
||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gradle
|
||||
.idea
|
||||
/build/
|
||||
target/
|
||||
*/target/
|
||||
bin/
|
||||
*/bin/
|
||||
out/
|
||||
*/out/
|
||||
68
.gitea/workflows/deploy.yml
Normal file
68
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Deploy to OCIWP
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Prepare Environment (Install Node.js & Git & Java 21)
|
||||
run: |
|
||||
if command -v apt-get &> /dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y nodejs git openjdk-21-jdk rsync
|
||||
elif command -v apk &> /dev/null; then
|
||||
apk add --no-cache nodejs git openjdk21 rsync
|
||||
fi
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Sync Code to Host Volume
|
||||
run: |
|
||||
echo "Copying latest code to /app (Host Volume) and removing stale files..."
|
||||
if command -v rsync &> /dev/null; then
|
||||
rsync -a --delete --exclude='.git' --exclude='bin' --exclude='act_runner_data' . /app/
|
||||
else
|
||||
rm -rf /app/dap-* /app/src /app/build.gradle /app/settings.gradle
|
||||
cp -a . /app/
|
||||
fi
|
||||
|
||||
- name: Deploy Task on Host
|
||||
run: |
|
||||
echo "Starting Standard CI/CD Deploy pipeline..."
|
||||
|
||||
# 1. runner에 설치된 java로 직접 빌드! (격리된 환경 문제 해결)
|
||||
cd /app
|
||||
chmod +x gradlew
|
||||
./gradlew bootJar -x test
|
||||
|
||||
# 2. 빌드 결과 plain jar 아카이브 정리
|
||||
find /app -name "*-plain.jar" -delete
|
||||
|
||||
# 3. 좀비 컨테이너 청소 (현재 우리가 쓸 포트를 점유 중인 옛날 컨테이너들만 정확히 색출해서 사살)
|
||||
echo "Finding and killing zombie containers holding our ports..."
|
||||
for port in 6379 8080 8089 8281 8282 8283 8284 8285 8288; do
|
||||
cid=$(docker ps -q --filter "publish=$port")
|
||||
if [ ! -z "$cid" ]; then
|
||||
echo "Force killing container $cid using port $port"
|
||||
docker rm -f $cid
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. 마운트된 /app 디렉토리로 이동하여 호스트의 도커 컴포즈 제어!
|
||||
cd /app
|
||||
docker system prune -f
|
||||
ACTIVE_PROFILE=dev docker compose up -d --build --pull always --remove-orphans gateway redis mci-mock dozzle was-sms was-oth
|
||||
|
||||
# 5. 배포 후 대롱대롱 매달려 있는 가비지 이미지 및 빌드 캐시 자동 소거 청소!
|
||||
docker image prune -a -f
|
||||
docker builder prune -a -f
|
||||
|
||||
echo "CI/CD Deploy Success!"
|
||||
62
.gitignore
vendored
62
.gitignore
vendored
@@ -56,11 +56,69 @@ build/
|
||||
# ===== VS Code =====
|
||||
.vscode/
|
||||
|
||||
# ===== 鍮뚮뱶 寃곌낵臾?=====
|
||||
target/
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
*.class
|
||||
*.jar
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
*.war
|
||||
*.ear
|
||||
*.nar
|
||||
|
||||
# ===== 濡쒓렇 =====
|
||||
*.log
|
||||
logs/
|
||||
spring-shell.log
|
||||
|
||||
# ===== Maven =====
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
pom.xml.next
|
||||
release.properties
|
||||
dependency-reduced-pom.xml
|
||||
buildNumber.properties
|
||||
.mvn/timing.properties
|
||||
|
||||
# ===== IntelliJ IDEA =====
|
||||
.idea/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
|
||||
# ===== Eclipse / STS =====
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
|
||||
# ===== NetBeans =====
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
.gradle/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
# ===== VS Code =====
|
||||
.vscode/
|
||||
|
||||
# ===== OS =====
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# ===== ?섍꼍?ㅼ젙 (誘쇨컧?뺣낫 遺꾨━ ?? =====
|
||||
# application-local.properties
|
||||
# application-secret.properties
|
||||
# application-local.yml
|
||||
# application-secret.yml
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,30 +1,50 @@
|
||||
# 1. 鍮뚮뱶 ?섍꼍 (JDK 21)
|
||||
# 1. 빌드 환경 (JDK 21)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 외부 인터넷이 차단된 내부망에서는 Docker Hub 대신 사내 Container Registry의
|
||||
# Java 21 이미지를 사용합니다. 인프라 담당자에게 이미지의 전체 경로와 태그를
|
||||
# 받은 후 아래 FROM 행만 교체합니다.
|
||||
#
|
||||
# 예: FROM registry.shinhanlife.co.kr/base/openjdk:21 AS builder
|
||||
#
|
||||
# 이 단계는 Gradle 빌드와 Java 컴파일을 수행하므로 반드시 JDK 21 이미지여야 합니다.
|
||||
# 사내 이미지가 실제로 JDK 21인지 다음 명령으로 확인합니다.
|
||||
# docker run --rm <사내-JDK-이미지> java -version
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Gradle ?섑띁? ?뚯뒪 蹂듭궗
|
||||
# Gradle Wrapper와 소스 파일을 복사합니다.
|
||||
COPY gradlew .
|
||||
COPY gradle gradle
|
||||
COPY build.gradle settings.gradle ./
|
||||
COPY src src
|
||||
|
||||
# 沅뚰븳 遺??諛?鍮뚮뱶 (?뚯뒪???쒖쇅)
|
||||
# Wrapper 실행 권한을 부여하고 테스트를 제외한 빌드를 수행합니다.
|
||||
RUN chmod +x gradlew
|
||||
RUN ./gradlew clean build -x test
|
||||
|
||||
# 2. ?ㅽ뻾 ?섍꼍 (JRE 21)
|
||||
# 2. 실행 환경 (JRE 21)
|
||||
# -----------------------------------------------------------------------------
|
||||
# 실행 단계는 JRE 21 이미지가 가장 가볍지만, 사내에서 JDK 21 이미지만 제공하는
|
||||
# 경우에는 동일한 JDK 21 이미지를 사용해도 정상 동작합니다.
|
||||
#
|
||||
# 예: FROM registry.shinhanlife.co.kr/base/openjre:21
|
||||
# 예: FROM registry.shinhanlife.co.kr/base/openjdk:21
|
||||
#
|
||||
# 사내 제공 이미지의 기반 OS를 확인합니다.
|
||||
# - Alpine 기반: 아래 apk 명령을 그대로 사용합니다.
|
||||
# - Ubuntu/Debian 기반: apk 대신 apt-get update && apt-get install -y tzdata를 사용합니다.
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# ??꾩〈 ?ㅼ젙 (?쒓뎅 ?쒓컙)
|
||||
# 서울 시간대를 설정합니다.
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Asia/Seoul
|
||||
|
||||
# 鍮뚮뱶??JAR ?뚯씪 蹂듭궗
|
||||
# 빌드 단계에서 생성한 애플리케이션 JAR를 복사합니다.
|
||||
COPY --from=builder /app/build/libs/*.jar app.jar
|
||||
|
||||
# 湲곕낯 ?ы듃 ?몄텧
|
||||
# 애플리케이션 포트를 노출합니다.
|
||||
EXPOSE 8081
|
||||
|
||||
# 而⑦뀒?대꼫 ?ㅽ뻾 ??JAR ?ㅽ뻾
|
||||
# 애플리케이션을 실행합니다.
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -1,3 +1,17 @@
|
||||
/**
|
||||
* @package io.shinhanlife
|
||||
* @className McpBridge
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URI;
|
||||
|
||||
458
README.md
458
README.md
@@ -1,164 +1,306 @@
|
||||
# DAP Backend
|
||||
# AX HUB MCP Tool Platform
|
||||
|
||||
Spring Boot 기반 DAP 관리자 백엔드 API 서버 및 MCP(Model Context Protocol) Gateway / Tool 분산 서버 프로젝트입니다.
|
||||
AX HUB에서 AI Agent가 업무 Tool을 검색하고 호출할 수 있도록 Gateway와 독립 Tool Pod를 제공하는 멀티 모듈 Spring Boot 프로젝트입니다.
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요 (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-other:bootRun` (또는 dap-tool-payment 등)
|
||||
- Tool 서버가 기동되면 자동으로 Gateway(8081)에 자신을 등록(Auto-Registration)합니다.
|
||||
- **(선택) 특정 Tool 그룹만 실행하기:**
|
||||
- 업무 특성에 따라 세분화된 그룹에 속한 Tool만 띄우고 싶다면, 실행 인수에 `--mcp.tool.target=그룹명`을 추가합니다.
|
||||
- **지원되는 그룹명:**
|
||||
- `NOTIFICATION`: 이메일, SMS 발송
|
||||
- `CLAIM`: 청구 처리, 심사 상태 조회
|
||||
- `POLICY`: 증권 발행, 발행 가능 여부 조회
|
||||
- `HR`: 휴가 등록, 연차 갯수 조회
|
||||
- `CONTRACT`: 계약 상태, 계약 상세 조회
|
||||
- `CUSTOMER`: 고객 등급, 고객 상세 정보 조회
|
||||
- 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=common`)를 수정하여 원하는 도메인의 툴만 선택적으로 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.properties`의 `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 (기능) 생성 탭**: 생성된 모듈에 새로운 툴(서비스/DTO) 코드를 자동으로 주입합니다.
|
||||
|
||||
### 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.common.util.PodScaffolder [모듈명] [포트번호]
|
||||
|
||||
# 실행 예시 (dap-tool-hr 모듈을 8086 포트로 생성)
|
||||
java -cp dap-common/src/main/java io.shinhanlife.dap.common.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.common.util.ToolScaffolder [Tool이름] [인터페이스ID] "[기능설명]" "[그룹명]" "[통신방식]" "[모듈명]"
|
||||
|
||||
# 실행 예시 (payment 모듈에 결제 승인 기능 추가)
|
||||
java -cp dap-common/src/main/java io.shinhanlife.dap.common.util.ToolScaffolder PaymentApproval PAY_001 "결제 승인 처리 기능" "COMMON" "HTTP" "dap-tool-payment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 패키지 구조 (Package Structure)
|
||||
## 1. 현재 구성
|
||||
|
||||
```text
|
||||
dap-backend-main (Root)
|
||||
├── dap-gateway # MCP 라우팅 허브 서버 (외부 LLM과 통신 및 Tool 분배)
|
||||
├── dap-common # 공통 모듈 (Security, Session, Config 등)
|
||||
├── dap-tool-core # Tool 공통 기능 (AbstractMcpToolService, Annotation, Scaffolder)
|
||||
├── dap-tool-email # [Tool] 이메일 발송 특화 어댑터 모듈
|
||||
├── dap-tool-sms # [Tool] SMS 발송 특화 어댑터 모듈
|
||||
├── dap-tool-payment # [Tool] 결제 비즈니스 어댑터 모듈 (Scaffolded)
|
||||
└── dap-tool-other # [Tool] 기타 비즈니스(청구, 계약, 고객, HR 등) 어댑터 모듈
|
||||
MCP Client / AI Agent
|
||||
|
|
||||
v
|
||||
Gateway (dap-gateway, 8081)
|
||||
- MCP 연결, Tool Registry, 인증·인가, 라우팅, 공통 화면
|
||||
|
|
||||
+-------------------------+
|
||||
| |
|
||||
v v
|
||||
SMS Tool Pod (dap-was-sms, 8082) OTH Tool Pod (dap-was-oth, 8084)
|
||||
- SMS 업무 Tool - 공통/업무/MCI 연계 Tool
|
||||
- Tool Manifest - Tool Manifest
|
||||
- Pod Test Console - Pod Test Console
|
||||
|
|
||||
v
|
||||
MCI / EAI / EIMS 등 레거시 연계 대상
|
||||
```
|
||||
|
||||
*(참고: 기존 단일 모듈 프로젝트에서 마이크로서비스 확장을 위해 모듈별로 분리되었으며, 각 Tool 서버는 독립적으로 확장 및 배포할 수 있습니다.)*
|
||||
Gateway는 외부 MCP 진입점과 Tool 등록·라우팅을 담당하고, 실제 업무 로직과 레거시 호출은 각 Tool Pod가 담당합니다. Tool Pod는 업무/조직 단위로 독립 배포할 수 있습니다.
|
||||
|
||||
## 2. 모듈
|
||||
|
||||
| 모듈 | 역할 | 기본 포트 |
|
||||
|---|---|---:|
|
||||
| `dap-gateway` | MCP Gateway, Registry, 라우팅, Chat·Catalog·Tester·Scaffold 화면 | 8081 |
|
||||
| `dap-was-lib` | Tool 공통 라이브러리: 어노테이션, Schema, Manifest, MCI/EAI 어댑터, 공통 Web·보안 기능 | - |
|
||||
| `dap-was-sms` | SMS/알림 업무 Tool Pod | 8082 |
|
||||
| `dap-was-oth` | 공통/샘플/MCI 연계 업무 Tool Pod | 8084 |
|
||||
|
||||
> 기존 `dap-tool-core`, `dap-tool-sms`, `dap-tool-oth` 명칭은 현재 각각 `dap-was-lib`, `dap-was-sms`, `dap-was-oth`로 변경되었습니다.
|
||||
|
||||
## 3. 개발 환경
|
||||
|
||||
| 항목 | 기준 |
|
||||
|---|---|
|
||||
| Java | 21 |
|
||||
| Spring Boot | 3.5.11 |
|
||||
| Gradle | 8.14.3 (Wrapper) |
|
||||
| Spring AI BOM | 1.1.8 |
|
||||
| 기본 프로필 | `local` |
|
||||
|
||||
로컬 환경에서는 민감한 값은 환경 변수로만 주입합니다.
|
||||
|
||||
```powershell
|
||||
$env:SPRING_PROFILES_ACTIVE = 'local'
|
||||
$env:OPENROUTER_API_KEY = '<발급받은-키>'
|
||||
```
|
||||
|
||||
## 4. 실행
|
||||
|
||||
### 4.1 Gradle 로컬 실행
|
||||
|
||||
각 애플리케이션은 별도 터미널에서 실행합니다.
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :dap-gateway:bootRun
|
||||
.\gradlew.bat :dap-was-sms:bootRun
|
||||
.\gradlew.bat :dap-was-oth:bootRun
|
||||
```
|
||||
|
||||
### 4.2 Docker Compose 실행
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
| 서비스 | 호스트 URL | 컨테이너 포트 |
|
||||
|---|---|---:|
|
||||
| Gateway | http://localhost:8281 | 8081 |
|
||||
| SMS Tool Pod | http://localhost:8282 | 8082 |
|
||||
| OTH Tool Pod | http://localhost:8284 | 8084 |
|
||||
| MCI Mock | http://localhost:8089 | 8080 |
|
||||
| Dozzle 로그 화면 | http://localhost:8288 | 8080 |
|
||||
|
||||
## 5. 화면과 운영 도구
|
||||
|
||||
### Gateway 화면
|
||||
|
||||
| 화면 | 로컬 URL | 용도 |
|
||||
|---|---|---|
|
||||
| 메인 | http://localhost:8081/index.html | 플랫폼 진입 화면 |
|
||||
| Catalog | http://localhost:8081/catalog.html | 등록 Tool 탐색 |
|
||||
| Playground | http://localhost:8081/playground.html | 단건 Tool 호출 확인 |
|
||||
| Chat | http://localhost:8081/chat.html | Agent 기반 대화형 호출 |
|
||||
| Auto Tester | http://localhost:8081/tester.html | Gateway 기준 다중 Tool 스모크/회귀 테스트 |
|
||||
| Scaffold | http://localhost:8081/admin/scaffold.html | Pod·Tool 소스 생성 지원 |
|
||||
|
||||
Docker Compose로 실행한 경우 Gateway 화면은 포트 `8281`을 사용합니다.
|
||||
|
||||
### Tool Pod Test Console
|
||||
|
||||
공통 정적 화면인 `tool-test-console.html`은 Tool Pod의 `/tool-manifest`를 읽어, 해당 Pod에 등록된 Tool과 `inputSchema`를 기준으로 요청 JSON을 만들어 직접 실행합니다.
|
||||
|
||||
| 대상 Pod | 로컬 URL | Docker Compose URL |
|
||||
|---|---|---|
|
||||
| OTH | http://localhost:8084/tool-test-console.html | http://localhost:8284/tool-test-console.html |
|
||||
| SMS | http://localhost:8082/tool-test-console.html | http://localhost:8282/tool-test-console.html |
|
||||
|
||||
사용 방법은 다음과 같습니다.
|
||||
|
||||
1. 대상 Tool Pod의 Console에 접속합니다.
|
||||
2. Tool을 선택하고 `Schema 샘플 채우기`로 요청 JSON을 생성합니다.
|
||||
3. 업무에 맞는 값으로 보정한 뒤 실행합니다.
|
||||
4. 재사용할 요청은 `현재 요청 저장`으로 브라우저 `localStorage`에 저장합니다.
|
||||
|
||||
### Gateway Auto Tester
|
||||
|
||||
`tester.html`은 Gateway에 등록된 Tool을 대상으로 다음 기능을 제공합니다.
|
||||
|
||||
- Tool 목록 자동 조회 및 Tool별 실행
|
||||
- JSON Schema 기반 더미 요청 생성, 사용자 요청 JSON 저장
|
||||
- 필터된 Tool 일괄 실행 및 실패 Tool 재실행
|
||||
- 결과 차트, 실시간 실행 로그, CSV 내보내기, 호출 예제 복사
|
||||
- Tool별 자동 Ping(3초 간격) 및 Stress Test(현재 50건 동시 요청)
|
||||
|
||||
> Auto Tester의 더미 요청 성공은 **통신·등록·기본 스키마 확인용 스모크 테스트**입니다. 실제 MCI/EAI 업무 규칙, 권한, 데이터 정합성은 보장하지 않습니다. Stress Test와 Auto Ping은 실제 Tool·레거시 호출을 유발할 수 있으므로 개발/테스트 환경 및 승인된 테스트 데이터에서만 사용합니다.
|
||||
|
||||
UI에서 사용하는 Tailwind CSS와 Chart.js는 `dap-gateway/src/main/resources/static/lib`에 포함되어 있어, 화면 라이브러리 로딩을 위해 외부 CDN 연결이 필요하지 않습니다.
|
||||
|
||||
## 6. 주요 HTTP API
|
||||
|
||||
### Gateway API
|
||||
|
||||
| 기능 | Method | 경로 |
|
||||
|---|---|---|
|
||||
| 등록 Tool 목록 | GET | `/mcp/api/v1/tools/list` |
|
||||
| Gateway를 통한 Tool 호출 | POST | `/mcp/api/v1/tools/call` |
|
||||
| Tool 문서 Markdown | GET | `/mcp/api/v1/tools/docs/markdown` |
|
||||
| Tool 등록 | POST | `/mcp/api/v1/registry/register` |
|
||||
| Tool 해제 | POST | `/mcp/api/v1/registry/deregister` |
|
||||
| Heartbeat | POST | `/mcp/api/v1/registry/heartbeat` |
|
||||
|
||||
호출 본문은 JSON-RPC 형식을 사용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "cmm_claim_search",
|
||||
"arguments": {
|
||||
"claimNo": "CLM2026070100120"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Pod API
|
||||
|
||||
| 기능 | Method | 경로 |
|
||||
|---|---|---|
|
||||
| 현재 Pod의 Tool Manifest | GET | `/tool-manifest` |
|
||||
| 현재 Pod의 로컬 Tool 목록 | GET | `/mcp/api/v1/tools/local` |
|
||||
| 현재 Pod에 직접 Tool 호출 | POST | `/mcp/{toolName}` |
|
||||
|
||||
Agent나 외부 클라이언트의 표준 MCP 진입은 Gateway를 사용합니다. Pod 직접 호출은 개발·단위 테스트·Pod Console 용도로 사용합니다.
|
||||
|
||||
## 7. Tool 개발 규칙
|
||||
|
||||
### 이름
|
||||
|
||||
Tool 함수명은 아래 4단계 규칙을 사용합니다.
|
||||
|
||||
```text
|
||||
pod_domain_service_action
|
||||
예: cmm_claim_search
|
||||
```
|
||||
|
||||
- `pod`: Tool Pod 식별자 (`oth`, `sms` 등)
|
||||
- `domain`: 업무 도메인 또는 `categoryKey` (`cmm`, `smp` 등)
|
||||
- `service`: 업무 서비스명
|
||||
- `action`: 동작 (`search`, `issue`, `send` 등)
|
||||
|
||||
### 기본 구현 계층
|
||||
|
||||
```text
|
||||
Request DTO
|
||||
-> UseCase Interface
|
||||
-> UseCaseImpl
|
||||
-> Converter (AI DTO <-> MCI/EAI Interface IO)
|
||||
-> Client / Adapter
|
||||
-> Legacy System
|
||||
```
|
||||
|
||||
- Request DTO에는 `@McpValidation`으로 단순 입력 검증을 선언합니다.
|
||||
- 복잡하거나 조건부 규칙이 필요한 입력은 `tool-schemas/{categoryKey}/...-input-schema.json` 리소스를 사용합니다.
|
||||
- 응답은 `@McpOutputSchema`로 간단한 설명을 제공하고, 복잡한 응답은 Output Schema 리소스로 명시합니다.
|
||||
- `null`의 업무 의미, 조건부 필드, 배열 정렬 기준, `hasMore` 여부, 민감 정보 제외 원칙을 Schema 설명에 명확히 씁니다.
|
||||
|
||||
### Schema 우선순위
|
||||
|
||||
1. `inputSchemaResource` 또는 `outputSchemaResource`가 지정된 경우: 해당 JSON Schema를 사용합니다.
|
||||
2. 리소스가 없으면: DTO 어노테이션(`@McpValidation`, `@McpOutputSchema`)과 공통 Generator로 생성합니다.
|
||||
|
||||
## 8. Manifest와 Registry
|
||||
|
||||
각 Tool Pod는 기동 시 `@McpTool`, `@McpFunction` 정보를 읽어 `/tool-manifest`를 제공합니다. Manifest에는 Tool 이름, 설명, endpoint, revision, Input/Output Schema가 포함됩니다.
|
||||
|
||||
Gateway는 Registry 등록 및 Heartbeat 정보를 이용해 Tool을 라우팅합니다. Tool Pod가 추가되어도 동일한 Manifest/Registry 규약을 준수하면 Gateway에서 탐색·호출할 수 있습니다.
|
||||
|
||||
## 9. MCI/EAI 연계
|
||||
|
||||
공통 MCI/EAI 연계 기능과 Glow 기본 설정은 `dap-was-lib`에서 제공합니다. Tool Pod별 애플리케이션 프로필은 각 Pod의 `application*.yml`에서 관리합니다.
|
||||
|
||||
```text
|
||||
dap-was-lib/src/main/resources/glow/application-glow*.yml
|
||||
dap-was-sms/src/main/resources/application*.yml
|
||||
dap-was-oth/src/main/resources/application*.yml
|
||||
```
|
||||
|
||||
MCI 호출 주소는 일반적으로 `host + uri`로 구성합니다. 예를 들어 `host=https://dev-ichmci.shinhanlife.co.kr`, `uri=/ntl_mci/clc_rcv`이면 호출 대상은 다음과 같습니다.
|
||||
|
||||
```text
|
||||
https://dev-ichmci.shinhanlife.co.kr/ntl_mci/clc_rcv
|
||||
```
|
||||
|
||||
`receive-uri`는 Tool이 호출할 주소가 아니라, MCI가 비동기/콜백 방식으로 응답을 전달하도록 별도 계약된 경우 수신에 사용하는 경로입니다.
|
||||
## 10. 품질 검증
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat :dap-was-lib:test
|
||||
.\gradlew.bat :dap-was-oth:compileJava
|
||||
.\gradlew.bat :dap-was-sms:compileJava
|
||||
.\gradlew.bat validateMcpToolNames
|
||||
```
|
||||
|
||||
배포 전에는 다음을 확인합니다.
|
||||
|
||||
- Tool 이름의 전역 중복 여부와 `pod_domain_service_action` 규칙(아래 확인 사항 반영 후)
|
||||
- Request/Response Schema 및 실제 예제 JSON
|
||||
- Tool Pod 단위 테스트와 Gateway 경유 호출
|
||||
- MCI/EAI 오류 코드의 사용자용 응답 매핑
|
||||
- 민감정보가 요청·응답·로그에 포함되지 않는지
|
||||
- 승인된 개발/테스트 계정과 데이터만 사용했는지
|
||||
|
||||
## 11. 보안과 운영 원칙
|
||||
|
||||
- API Key, Runner 등록 토큰, 계정·비밀번호 등 비밀값은 Git에 넣지 않고 환경 변수 또는 Secret Manager로 주입합니다.
|
||||
- `TESTER-DEV` 같은 테스트 권한은 개발 환경에서만 허용하고, 운영에서는 Tool 단위 최소 권한으로 제한합니다.
|
||||
- Tool 요청·응답 로그는 마스킹/제외 정책을 거친 값만 남깁니다.
|
||||
- Trace ID는 요청 흐름 전체를 추적하고, Request ID는 HTTP 호출 단위로 새로 부여합니다.
|
||||
- Tool Pod는 업무 소유권과 장애 격리 단위에 맞춰 독립 이미지·배포·운영 책임으로 분리합니다.
|
||||
|
||||
## 12. 참고 소스 위치
|
||||
|
||||
| 기능 | 위치 |
|
||||
|---|---|
|
||||
| Tool 공통 Controller | `dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/presentation` |
|
||||
| Tool 어노테이션 | `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/annotation` |
|
||||
| JSON Schema/검증 | `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/schema`, `.../validation` |
|
||||
| Manifest | `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest` |
|
||||
| Tool Scaffold | `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java` |
|
||||
| Gateway 라우터 | `dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/McpRouterController.java` |
|
||||
| OTH Tool | `dap-was-oth/src/main/java` |
|
||||
| SMS Tool | `dap-was-sms/src/main/java` |
|
||||
## 13. 모듈명 전환 검증
|
||||
|
||||
Tool 관련 공통 기능은 `dap-was-*` 모듈명만 기준으로 동작합니다.
|
||||
|
||||
- `validateMcpToolNames`는 `dap-was-*` Tool Pod를 탐색하여 이름 규칙과 전역 중복을 검사합니다.
|
||||
- Tool Scaffold는 Pod 이름을 Tool 함수명에 포함하지 않습니다. 함수명은 `도메인_비즈니스_행위` 형식입니다. 예: `cmm_notification_send`
|
||||
- Pod Scaffold와 Gateway Scaffold 화면/API의 모듈 목록도 `dap-was-*` 명칭으로 통일되어 있습니다.
|
||||
- Tool Source Update 기능은 `dap-was-*` 아래의 `*UseCase.java`를 검색합니다.
|
||||
|
||||
## 14. BC-DAB-STD-003 Tool Schema V17 적용
|
||||
|
||||
각 Tool의 표준 명세는 Tool Pod별 다음 경로에서 관리합니다.
|
||||
|
||||
```text
|
||||
dap-was-{pod}/src/main/resources/tool-definitions/{categoryKey}/{toolName}.yml
|
||||
```
|
||||
|
||||
Tool 이름은 Pod 정보를 포함하지 않는 `도메인_서비스_행위` 형태의 영문 소문자 snake_case를 사용하며,
|
||||
정규식 `^[a-z][a-z0-9_]{2,63}$`을 만족해야 합니다. 예: `cmm_claim_search`.
|
||||
|
||||
필수 항목은 `name`, `display_name`, `version`, `category_key`, 설명 4개 요소(function, when_to_use,
|
||||
when_not_to_use, io_limits), `display_description`, 예시 질의 3~10건, 동작 힌트 3개(read_only,
|
||||
destructive, idempotent), `parameters_schema`입니다. 입력 Schema는 루트 `type: object`, 각 property의
|
||||
`description`, `additionalProperties: false`를 갖춰야 합니다. 선택 운영 항목은 `tags`,
|
||||
`legacy_interface_id`, `required_env_keys`, `owner_org`입니다.
|
||||
|
||||
기동 시 `tool-definitions/**/*.yml`을 한 번 읽어 이름 기준으로 캐시하고, `@McpTool` 실행 정보와 결합한
|
||||
동일한 `ToolMetadata`를 `/tool-manifest`, Tool Pod MCP, Gateway MCP에 사용합니다.
|
||||
|
||||
입력 Schema 우선순위는 `inputSchemaResource` → V17 `parameters_schema` → DTO 자동 생성이고, 출력은
|
||||
`outputSchemaResource` → 명시 Output Schema → `@McpOutputSchema` 기반 생성입니다. Output Schema를
|
||||
명시한 Tool만 최종 응답 검증을 수행합니다.
|
||||
|
||||
Scaffold 화면의 `Tool Schema V17 Metadata` 영역에서는 기능 설명, 사용/비사용 조건, 입출력 제한,
|
||||
표시 설명, 예시 질의, 태그와 소유 조직을 입력합니다. Java 소스와 함께 V17 YAML이 생성되며, 기본값은
|
||||
배포 전에 업무 담당자가 실제 의미에 맞게 검토해야 합니다.
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat validateMcpToolNames validateToolSchemaV17
|
||||
```
|
||||
|
||||
`bootJar`는 두 검증에 의존하므로 이름 중복, 필수 항목 누락, Java Tool과 YAML 명세 불일치가 있으면
|
||||
Docker 이미지 생성 전에 빌드가 실패합니다.
|
||||
|
||||
66
build.gradle
66
build.gradle
@@ -1,51 +1,107 @@
|
||||
// 루트 Gradle 설정: 모든 Gateway/Tool Pod 모듈이 공유하는 빌드 기준을 정의합니다.
|
||||
plugins {
|
||||
// Java 컴파일, 테스트, JAR 생성 기능을 제공합니다.
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '4.0.5' apply false
|
||||
|
||||
// 하위 실행 모듈에서 bootRun/bootJar를 사용하기 위한 Spring Boot 플러그인입니다.
|
||||
// 루트 프로젝트에는 적용하지 않으므로 apply false를 사용합니다.
|
||||
id 'org.springframework.boot' version '3.5.11' apply false
|
||||
|
||||
// Spring Boot / Spring AI BOM에 정의된 라이브러리 버전을 일관되게 적용합니다.
|
||||
id 'io.spring.dependency-management' version '1.1.6' apply false
|
||||
}
|
||||
|
||||
// 모든 모듈이 공유하는 Maven 식별자입니다.
|
||||
allprojects {
|
||||
group = 'io.shinhanlife'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
}
|
||||
|
||||
// dap-gateway, dap-was-lib, dap-was-oth, dap-was-sms에 공통 적용합니다.
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'io.spring.dependency-management'
|
||||
|
||||
java {
|
||||
// 프로젝트 표준 Java 버전입니다.
|
||||
sourceCompatibility = '21'
|
||||
}
|
||||
|
||||
repositories {
|
||||
// 현재 공개 정식 라이브러리 저장소입니다.
|
||||
// 폐쇄망 적용 시 사내 Nexus Proxy URL로 교체합니다.
|
||||
mavenCentral()
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
maven { url 'https://repo.spring.io/snapshot' }
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
// Spring Boot 3.5.11과 호환되는 Spring/Jackson/Tomcat 등의 버전을 관리합니다.
|
||||
mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES
|
||||
mavenBom "org.springframework.ai:spring-ai-bom:2.0.0"
|
||||
|
||||
// Spring AI, MCP, OpenAI 연동 라이브러리의 호환 버전을 1.1.8로 고정합니다.
|
||||
mavenBom 'org.springframework.ai:spring-ai-bom:1.1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Lombok은 컴파일 시 getter/builder 등 반복 코드를 생성하며 실행 JAR에는 포함하지 않습니다.
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
|
||||
testCompileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
|
||||
// Tool DTO와 MCI 요청/응답 객체 간 Converter 구현체를 컴파일 시 자동 생성합니다.
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
|
||||
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
|
||||
|
||||
// 모든 모듈의 JUnit 5 기반 테스트 공통 의존성입니다.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile) {
|
||||
// 리플렉션/MCP Schema 생성 시 메서드 파라미터명을 사용할 수 있도록 보존합니다.
|
||||
options.compilerArgs << '-parameters'
|
||||
|
||||
// MapStruct 구현체를 Spring Bean으로 생성해 생성자 주입으로 사용할 수 있게 합니다.
|
||||
options.compilerArgs << '-Amapstruct.defaultComponentModel=spring'
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
// JUnit 5 테스트 플랫폼을 사용합니다.
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
// Tool 이름 중복 검사 프로그램이 포함된 공통 라이브러리 모듈입니다.
|
||||
def toolCoreProject = project(':dap-was-lib')
|
||||
|
||||
// 전체 Tool Pod의 @McpTool(name) 중복을 배포 산출물 생성 전에 차단합니다.
|
||||
tasks.register('validateMcpToolNames', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Checks duplicate @McpTool names across all Tool modules before packaging.'
|
||||
|
||||
// 검사 Runner를 실행하기 전에 dap-was-lib 클래스를 먼저 컴파일합니다.
|
||||
dependsOn toolCoreProject.tasks.named('classes')
|
||||
classpath = toolCoreProject.sourceSets.main.runtimeClasspath
|
||||
mainClass.set('io.shinhanlife.dap.lib.validation.McpToolNameValidationRunner')
|
||||
args rootProject.projectDir.absolutePath
|
||||
}
|
||||
|
||||
tasks.register('validateToolSchemaV17', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Validates BC-DAB-STD-003 V17 definitions for every @McpTool.'
|
||||
dependsOn toolCoreProject.tasks.named('classes')
|
||||
classpath = toolCoreProject.sourceSets.main.runtimeClasspath
|
||||
mainClass.set('io.shinhanlife.dap.lib.validation.ToolSchemaV17ValidationRunner')
|
||||
args rootProject.projectDir.absolutePath
|
||||
}
|
||||
|
||||
subprojects {
|
||||
// 배포용 Spring Boot JAR 생성 전에 Tool 이름 중복 검증을 강제합니다.
|
||||
tasks.matching { it.name == 'bootJar' }.configureEach {
|
||||
dependsOn rootProject.tasks.named('validateMcpToolNames')
|
||||
dependsOn rootProject.tasks.named('validateToolSchemaV17')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-web'
|
||||
api 'org.springframework.boot:spring-boot-starter-validation'
|
||||
api 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
|
||||
api 'io.github.resilience4j:resilience4j-core:2.2.0'
|
||||
api 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
|
||||
api 'io.github.resilience4j:resilience4j-ratelimiter'
|
||||
api 'io.github.resilience4j:resilience4j-retry:2.2.0'
|
||||
api 'org.springframework.boot:spring-boot-starter-aop:3.3.0'
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
||||
api 'com.h2database:h2'
|
||||
api 'p6spy:p6spy:3.9.1'
|
||||
api 'io.lettuce:lettuce-core:6.6.0.RELEASE'
|
||||
|
||||
api "org.mapstruct:mapstruct:1.5.5.Final"
|
||||
|
||||
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 'org.springframework.kafka:spring-kafka:3.2.0'
|
||||
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.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.biz.mcp.gateway.dto
|
||||
* @className ToolMetadata
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </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초당 허용 최대 요청 수
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package io.shinhanlife.dap.common.integration.mci.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.integration.mci.dto
|
||||
* @className ShinhanCommonHeaderDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ShinhanCommonHeaderDto {
|
||||
|
||||
private String envrTypeCd; // 환경유형코드 (D, T, R)
|
||||
private String glbId; // 전사공통키 (37 Byte)
|
||||
private String pgrsSriaNo; // 진행일련번호
|
||||
private String trgmVrsnInfoValu; // 전문버전정보값
|
||||
private String tgrmEncrYn; // 전문암호화여부
|
||||
private String gpcpCd; // 글로벌법인코드
|
||||
private String appliDutjCd; // 어플리케이션업무코드
|
||||
private String rcvSvcId; // 수신서비스ID
|
||||
private String reqRspnScCd; // 요청응답구분코드 (S:요청, R:응답)
|
||||
private String inqrTraTypeCd; // 조회거래유형코드
|
||||
private String reqTgrmTnsmDtptDt; // 요청전문전송일시
|
||||
private String itrIfId; // 인터페이스ID
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.converter;
|
||||
|
||||
import io.shinhanlife.dap.common.session.dto.SessionDto;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacOutDto;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.converter
|
||||
* @className ZtUsacConverter
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
public abstract class ZtUsacConverter {
|
||||
|
||||
@Mapping(target = "loginDtm", ignore = true)
|
||||
@Mapping(target = "isManager", ignore = true)
|
||||
public abstract SessionDto toSessionDto(ZtUsacOutDto dto);
|
||||
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.domain.model;
|
||||
|
||||
import io.shinhanlife.glow.db.dto.AuditInfo;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.domain.model
|
||||
* @className ZtUsacModel
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
public class ZtUsacModel extends AuditInfo {
|
||||
|
||||
/* 인사번호 */
|
||||
private String prafNo;
|
||||
/* 인사명 */
|
||||
private String prafNm;
|
||||
/* 조직번호 */
|
||||
private String ognzNo;
|
||||
/* 이메일주소 */
|
||||
private String addre;
|
||||
/* 인사직무코드 */
|
||||
private String prafOfduCd;
|
||||
/* 인사직무명 */
|
||||
private String prafOfduNm;
|
||||
/* 인사직급코드 */
|
||||
private String prafOfleCd;
|
||||
/* 인사직급명 */
|
||||
private String prafOfleNm;
|
||||
/* 인사직책코드 */
|
||||
private String prafDutyCd;
|
||||
/* 인사직책명 */
|
||||
private String prafDutyNm;
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.domain.repository;
|
||||
|
||||
import io.shinhanlife.glow.GlowMybatisMapper;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacInDto;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacOutDto;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.domain.repository
|
||||
* @className ZtUsacRepository
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@GlowMybatisMapper
|
||||
public interface ZtUsacRepository {
|
||||
|
||||
/**
|
||||
* 사용자 조회 (단건)
|
||||
*
|
||||
* @param dto 사번
|
||||
* @return 인사정보
|
||||
*/
|
||||
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.domain.service;
|
||||
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacInDto;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacOutDto;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.domain.service
|
||||
* @className ZtUsacService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface ZtUsacService {
|
||||
|
||||
/**
|
||||
* 사용자 조회 (단건)
|
||||
*
|
||||
* @param dto 사번
|
||||
* @return 인사정보
|
||||
*/
|
||||
ZtUsacOutDto selectZtUsac(ZtUsacInDto dto);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.domain.service.impl;
|
||||
|
||||
import io.shinhanlife.dap.common.session.domain.repository.ZtUsacRepository;
|
||||
import io.shinhanlife.dap.common.session.domain.service.ZtUsacService;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacInDto;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacOutDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.domain.service.impl
|
||||
* @className ZtUsacServiceImpl
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ZtUsacServiceImpl implements ZtUsacService {
|
||||
|
||||
private final ZtUsacRepository ztUsacRepository;
|
||||
|
||||
/**
|
||||
* 사용자 조회 (단건)
|
||||
*
|
||||
* @param dto 사번
|
||||
* @return 인사정보
|
||||
*/
|
||||
@Override
|
||||
public ZtUsacOutDto selectZtUsac(ZtUsacInDto dto) {
|
||||
ZtUsacOutDto result = ztUsacRepository.selectZtUsac(dto);
|
||||
result.initLists();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.dto;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.dto
|
||||
* @className ZtUsacInDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class ZtUsacInDto {
|
||||
/* 인사번호 */
|
||||
private String prafNo;
|
||||
|
||||
/* 사용여부 */
|
||||
@Builder.Default
|
||||
private String puseYn = "Y";
|
||||
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.dto;
|
||||
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.dto
|
||||
* @className ZtUsacOutDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
@Setter
|
||||
public class ZtUsacOutDto {
|
||||
|
||||
/* 인사번호 */
|
||||
private String prafNo;
|
||||
/* 인사명 */
|
||||
private String prafNm;
|
||||
/* 조직번호 */
|
||||
private String ognzNo;
|
||||
/* 조직명 */
|
||||
private String ognzNm;
|
||||
/* 이메일주소 */
|
||||
// @GlowSecureField(type = DataSecureType.DECRYPT_DB, direction = SafeDBType.COMM) TODO 방화벽 뚫리면 확인
|
||||
private String addre;
|
||||
/* 인사직무코드 */
|
||||
private String prafOfduCd;
|
||||
/* 인사직무명 */
|
||||
private String prafOfduNm;
|
||||
/* 인사직급코드 */
|
||||
private String prafOfleCd;
|
||||
/* 인사직급명 */
|
||||
private String prafOfleNm;
|
||||
/* 인사직책코드 */
|
||||
private String prafDutyCd;
|
||||
/* 인사직책명 */
|
||||
private String prafDutyNm;
|
||||
/* 사용여부 */
|
||||
private String puseYn;
|
||||
|
||||
private String roleNoStrList;
|
||||
private String roleNmStrList;
|
||||
private String tgtrPrafNoStrList;
|
||||
private String tgtrOgnzNoStrList;
|
||||
|
||||
private List<String> roleNoList;
|
||||
private List<String> roleNmList;
|
||||
private List<String> tgtrPrafNoList;
|
||||
private List<String> tgtrOgnzNoList;
|
||||
|
||||
public void initLists() {
|
||||
this.roleNoList = convertStrToList(roleNoStrList);
|
||||
this.roleNmList = convertStrToList(roleNmStrList);
|
||||
this.tgtrPrafNoList = convertStrToList(tgtrPrafNoStrList);
|
||||
this.tgtrOgnzNoList = convertStrToList(tgtrOgnzNoStrList);
|
||||
}
|
||||
|
||||
private List<String> convertStrToList(String str) {
|
||||
if (str == null || str.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return Arrays.asList(str.split(","));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.presentation;
|
||||
|
||||
import io.micrometer.common.util.StringUtils;
|
||||
import io.shinhanlife.glow.BaseResponse;
|
||||
import io.shinhanlife.glow.BizException;
|
||||
import io.shinhanlife.glow.GlowControllerId;
|
||||
import io.shinhanlife.glow.ResponseUtil;
|
||||
import io.shinhanlife.dap.common.session.converter.ZtUsacConverter;
|
||||
import io.shinhanlife.dap.common.session.domain.service.ZtUsacService;
|
||||
import io.shinhanlife.dap.common.session.dto.SessionDto;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacInDto;
|
||||
import io.shinhanlife.dap.common.session.dto.ZtUsacOutDto;
|
||||
import io.shinhanlife.dap.common.session.presentation.io.SsoResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.javassist.NotFoundException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.presentation
|
||||
* @className SsoRestController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@RequestMapping("/sso")
|
||||
public class SsoRestController {
|
||||
|
||||
private static final String NLS_LOGIN_URL = "";
|
||||
private final ZtUsacService ztUsacService;
|
||||
private final ZtUsacConverter ztUsacConverter;
|
||||
|
||||
/**
|
||||
* sso 연동 전 임시 로그인
|
||||
*
|
||||
* @param request
|
||||
* @param response
|
||||
* @param session
|
||||
* @param <T>
|
||||
* @return
|
||||
*/
|
||||
@GlowControllerId("tempLogin")
|
||||
@PostMapping("/tempLogin")
|
||||
public <T> ResponseEntity<BaseResponse<SsoResponse>> tempLogin(HttpServletRequest request, HttpServletResponse response,
|
||||
HttpSession session, @RequestBody SessionDto requestDto) {
|
||||
try {
|
||||
if (StringUtils.isEmpty(requestDto.getPrafNo())) {
|
||||
throw new NotFoundException("SSO >> not found sso id");
|
||||
}
|
||||
|
||||
// DB 유저 가져오기
|
||||
ZtUsacOutDto ztUsacOutDto = ztUsacService.selectZtUsac(ZtUsacInDto.builder().prafNo(requestDto.getPrafNo()).puseYn("Y")
|
||||
.build());
|
||||
if (Objects.isNull(ztUsacOutDto) || StringUtils.isEmpty(ztUsacOutDto.getPrafNo())) {
|
||||
throw new BizException("SSO >> not found UserInfo >> retCode:");
|
||||
}
|
||||
|
||||
SessionDto sessionDto = ztUsacConverter.toSessionDto(ztUsacOutDto);
|
||||
sessionDto.setLoginDtm(); // 로그인 시점 세팅
|
||||
session.setAttribute("userInfo", sessionDto);
|
||||
return ResponseUtil.ok(SsoResponse.builder().retCode("0").userInfo(sessionDto).build());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("로그인 실패", e);
|
||||
session.invalidate();
|
||||
}
|
||||
|
||||
return ResponseUtil.ok(SsoResponse.builder().redirectUrl(NLS_LOGIN_URL).build());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package io.shinhanlife.dap.common.session.presentation.io;
|
||||
|
||||
import io.shinhanlife.dap.common.session.dto.SessionDto;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.common.session.presentation.io
|
||||
* @className SsoResponse
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SsoResponse {
|
||||
|
||||
private String retCode;
|
||||
private SessionDto userInfo;
|
||||
private String redirectUrl;
|
||||
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
package io.shinhanlife.dap.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* MCP Tool 코드를 자동 생성(Scaffolding)하는 유틸리티 클래스
|
||||
*
|
||||
* [실행 방법]
|
||||
* 방법 1. IDE(IntelliJ 등)에서 직접 실행 (대화형 모드 추천 ⭐)
|
||||
* - 이 클래스(ToolScaffolder.java)를 열고 main 메서드를 직접 실행(Run)합니다.
|
||||
* - 콘솔 창에 뜨는 질문에 차례대로 값을 입력하기만 하면 파일이 생성됩니다.
|
||||
*
|
||||
* 방법 2. 커맨드라인(터미널)에서 실행 (명령어 기반)
|
||||
* - 컴파일: javac -encoding UTF-8 dap-tool-core/src/main/java/io/shinhanlife/dap/biz/mcp/tool/util/ToolScaffolder.java
|
||||
* - 실행: java -cp dap-tool-core/src/main/java io.shinhanlife.dap.biz.mcp.tool.util.ToolScaffolder [이름] [ID] "[설명]" "[그룹]" "[통신방식]" "[모듈명]"
|
||||
*/
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.tool.util
|
||||
* @className ToolScaffolder
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public class ToolScaffolder {
|
||||
|
||||
private static final String BASE_PACKAGE = "io.shinhanlife.dap.biz.mcp.tool";
|
||||
private static final String BASE_PACKAGE_PATH = "src/main/java/io/shinhanlife/dap/biz/mcp/tool";
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
|
||||
System.out.println("=========================================");
|
||||
System.out.println(" MCP Tool Scaffolder (Java CLI) ");
|
||||
System.out.println("=========================================\n");
|
||||
|
||||
String baseName = getOrAsk(args, 0, scanner, "1. 생성할 Tool의 기본 이름 (예: ExchangeRate) [영문 PascalCase]: ");
|
||||
String interfaceId = getOrAsk(args, 1, scanner, "2. 레거시 API 인터페이스 ID (예: EXCH_001): ");
|
||||
String description = getOrAsk(args, 2, scanner, "3. Tool 기능 설명 (예: 환율 조회): ");
|
||||
String group = getOrAsk(args, 3, scanner, "4. Tool 소속 그룹 (예: NOTIFICATION, CLAIM, POLICY, HR, CONTRACT, CUSTOMER 등): ");
|
||||
if (group.isEmpty()) group = "COMMON";
|
||||
String routingType = getOrAsk(args, 4, scanner, "5. 통신 프로토콜 (예: HTTP, TCP, MCI, EAI): ");
|
||||
if (routingType.trim().isEmpty()) {
|
||||
routingType = "HTTP";
|
||||
}
|
||||
String moduleName = getOrAsk(args, 5, scanner, "6. 코드를 생성할 모듈 (기본: dap-tool-other): ");
|
||||
if (moduleName.trim().isEmpty()) {
|
||||
moduleName = "dap-tool-other";
|
||||
}
|
||||
|
||||
String defaultAuthor = System.getProperty("user.name");
|
||||
String defaultDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
|
||||
|
||||
String author = getOrAsk(args, 6, scanner, "7. 작성자 (엔터 입력 시 '" + defaultAuthor + "'): ");
|
||||
if (author.trim().isEmpty()) author = defaultAuthor;
|
||||
String createDate = getOrAsk(args, 7, scanner, "8. 작성일 (엔터 입력 시 '" + defaultDate + "'): ");
|
||||
if (createDate.trim().isEmpty()) createDate = defaultDate;
|
||||
|
||||
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, true);
|
||||
System.out.println(result);
|
||||
}
|
||||
|
||||
private static String getOrAsk(String[] args, int index, Scanner scanner, String prompt) {
|
||||
if (args.length > index) {
|
||||
return args[index];
|
||||
}
|
||||
System.out.print(prompt);
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
|
||||
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register) throws IOException {
|
||||
baseName = toPascalCase(baseName);
|
||||
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
|
||||
|
||||
Path serviceDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "service"));
|
||||
Path dtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, "dto"));
|
||||
|
||||
String shortName = moduleName.replace("dap-tool-", "").replace("-", "");
|
||||
Path legacyDtoDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, shortName, "dto"));
|
||||
Path converterDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, shortName, "converter"));
|
||||
|
||||
Files.createDirectories(serviceDir);
|
||||
Files.createDirectories(dtoDir);
|
||||
Files.createDirectories(legacyDtoDir);
|
||||
Files.createDirectories(converterDir);
|
||||
|
||||
StringBuilder log = new StringBuilder();
|
||||
|
||||
// Generate Req DTO
|
||||
String reqContent = """
|
||||
package %s.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @package %s.dto
|
||||
* @className %sReq
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author %s
|
||||
* @create %s
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* %s %s 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class %sReq {
|
||||
// TODO: Add request fields here
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, BASE_PACKAGE, baseName, author, createDate, createDate, author, baseName);
|
||||
Files.writeString(dtoDir.resolve(baseName + "Req.java"), reqContent);
|
||||
|
||||
// Generate Res DTO
|
||||
String resContent = """
|
||||
package %s.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @package %s.dto
|
||||
* @className %sRes
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author %s
|
||||
* @create %s
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* %s %s 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class %sRes {
|
||||
private String status;
|
||||
private String message;
|
||||
// TODO: Add response fields here
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, BASE_PACKAGE, baseName, author, createDate, createDate, author, baseName);
|
||||
Files.writeString(dtoDir.resolve(baseName + "Res.java"), resContent);
|
||||
|
||||
String toolName = baseName.isEmpty() ? baseName : Character.toLowerCase(baseName.charAt(0)) + baseName.substring(1);
|
||||
|
||||
// Generate Service
|
||||
String serviceContent = """
|
||||
package %s.service;
|
||||
|
||||
import %s.annotation.McpFunction;
|
||||
import %s.annotation.McpTool;
|
||||
import %s.dto.%sReq;
|
||||
import %s.dto.%sRes;
|
||||
import %s.%s.converter.%sLegacyConverter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/**
|
||||
* @package %s.service
|
||||
* @className %sService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author %s
|
||||
* @create %s
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* %s %s 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
@McpTool(
|
||||
routingType = "%s",
|
||||
categoryKey = "%s"
|
||||
)
|
||||
public class %sService extends AbstractMcpToolService {
|
||||
|
||||
private final %sLegacyConverter converter = Mappers.getMapper(%sLegacyConverter.class);
|
||||
|
||||
@McpFunction(
|
||||
displayName = "%s 툴",
|
||||
name = "%s",
|
||||
description = "%s",
|
||||
prompt = "%s",
|
||||
mappingId = "%s",
|
||||
register = %s,
|
||||
requiresApproval = false
|
||||
)
|
||||
public Object execute(%sReq req) {
|
||||
// %sLegacyReq legacyReq = converter.toLegacyReq(req);
|
||||
return executeLegacy("%s", "%s", req); // Or pass legacyReq
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
BASE_PACKAGE,
|
||||
BASE_PACKAGE,
|
||||
BASE_PACKAGE,
|
||||
BASE_PACKAGE, baseName,
|
||||
BASE_PACKAGE, baseName,
|
||||
BASE_PACKAGE, shortName, baseName,
|
||||
BASE_PACKAGE,
|
||||
baseName,
|
||||
author,
|
||||
createDate,
|
||||
createDate, author,
|
||||
routingType, group.toLowerCase(),
|
||||
baseName,
|
||||
baseName, baseName,
|
||||
baseName, toolName, description, description + " 해줘.", interfaceId, register,
|
||||
baseName,
|
||||
baseName,
|
||||
routingType, interfaceId
|
||||
);
|
||||
|
||||
Files.writeString(serviceDir.resolve(baseName + "Service.java"), serviceContent);
|
||||
|
||||
|
||||
|
||||
// Generate Legacy Req DTO
|
||||
String legacyReqContent = """
|
||||
package %s.%s.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @package %s.%s.dto
|
||||
* @className %sLegacyReq
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author %s
|
||||
* @create %s
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* %s %s 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
public class %sLegacyReq {
|
||||
// TODO: Add legacy request fields here
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, shortName, BASE_PACKAGE, shortName, baseName, author, createDate, createDate, author, baseName);
|
||||
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyReq.java"), legacyReqContent);
|
||||
|
||||
// Generate Legacy Res DTO
|
||||
String legacyResContent = """
|
||||
package %s.%s.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* @package %s.%s.dto
|
||||
* @className %sLegacyRes
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author %s
|
||||
* @create %s
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* %s %s 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
public class %sLegacyRes {
|
||||
// TODO: Add legacy response fields here
|
||||
}
|
||||
""".formatted(BASE_PACKAGE, shortName, BASE_PACKAGE, shortName, baseName, author, createDate, createDate, author, baseName);
|
||||
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyRes.java"), legacyResContent);
|
||||
|
||||
// Generate Legacy Converter
|
||||
String converterContent = """
|
||||
package %s.%s.converter;
|
||||
|
||||
import %s.dto.%sReq;
|
||||
import %s.dto.%sRes;
|
||||
import %s.%s.dto.%sLegacyReq;
|
||||
import %s.%s.dto.%sLegacyRes;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/**
|
||||
* @package %s.%s.converter
|
||||
* @className %sLegacyConverter
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author %s
|
||||
* @create %s
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* %s %s 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface %sLegacyConverter {
|
||||
|
||||
// @Mapping(source = "sourceField", target = "targetField")
|
||||
%sLegacyReq toLegacyReq(%sReq req);
|
||||
|
||||
%sRes toRes(%sLegacyRes legacyRes);
|
||||
}
|
||||
""".formatted(
|
||||
BASE_PACKAGE, shortName,
|
||||
BASE_PACKAGE, baseName,
|
||||
BASE_PACKAGE, baseName,
|
||||
BASE_PACKAGE, shortName, baseName,
|
||||
BASE_PACKAGE, shortName, baseName,
|
||||
BASE_PACKAGE, shortName, baseName, author, createDate, createDate, author,
|
||||
baseName, baseName, baseName, baseName, baseName, baseName
|
||||
);
|
||||
Files.writeString(converterDir.resolve(baseName + "LegacyConverter.java"), converterContent);
|
||||
|
||||
log.append("\n=========================================\n");
|
||||
log.append(" Scaffolding Complete!\n");
|
||||
log.append("=========================================\n");
|
||||
log.append("[Service] ").append(serviceDir.resolve(baseName + "Service.java")).append("\n");
|
||||
log.append("[Req DTO] ").append(dtoDir.resolve(baseName + "Req.java")).append("\n");
|
||||
log.append("[Res DTO] ").append(dtoDir.resolve(baseName + "Res.java")).append("\n");
|
||||
log.append("[Legacy Req DTO] ").append(legacyDtoDir.resolve(baseName + "LegacyReq.java")).append("\n");
|
||||
log.append("[Legacy Res DTO] ").append(legacyDtoDir.resolve(baseName + "LegacyRes.java")).append("\n");
|
||||
log.append("[Legacy Converter] ").append(converterDir.resolve(baseName + "LegacyConverter.java")).append("\n");
|
||||
log.append("\n Tip: ").append(interfaceId).append(" 목업 데이터를 mock-responses.json에 추가하세요.\n");
|
||||
|
||||
return log.toString();
|
||||
}
|
||||
|
||||
private static String toPascalCase(String str) {
|
||||
if (str == null || str.isEmpty()) {
|
||||
return str;
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean capitalizeNext = true;
|
||||
for (char c : str.toCharArray()) {
|
||||
if (c == '_' || c == '-' || c == ' ') {
|
||||
capitalizeNext = true;
|
||||
} else if (capitalizeNext) {
|
||||
result.append(Character.toUpperCase(c));
|
||||
capitalizeNext = false;
|
||||
} else {
|
||||
result.append(c);
|
||||
}
|
||||
}
|
||||
if (result.length() > 0) {
|
||||
result.setCharAt(0, Character.toUpperCase(result.charAt(0)));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package io.shinhanlife.dap.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ToolSourceUpdater {
|
||||
|
||||
public static void updateToolSource(String toolName, String domainGroup, String description, boolean register, Boolean requiresApproval) throws Exception {
|
||||
// 1. Find all *Service.java files in axhub-tool-* directories
|
||||
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
|
||||
|
||||
List<Path> javaFiles;
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
javaFiles = paths
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> p.toString().endsWith("Service.java"))
|
||||
.filter(p -> p.toString().contains("axhub-tool-"))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
Path targetFile = null;
|
||||
String content = null;
|
||||
|
||||
// 2. Find the specific file for the tool
|
||||
String functionName = toolName;
|
||||
if (toolName.contains("_")) {
|
||||
functionName = toolName.substring(toolName.indexOf("_") + 1);
|
||||
}
|
||||
|
||||
Pattern namePattern = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"", Pattern.DOTALL);
|
||||
Pattern namePattern2 = Pattern.compile("@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(functionName) + "\"", Pattern.DOTALL);
|
||||
|
||||
for (Path path : javaFiles) {
|
||||
String text = Files.readString(path);
|
||||
if (namePattern.matcher(text).find()) {
|
||||
targetFile = path;
|
||||
content = text;
|
||||
break;
|
||||
} else if (namePattern2.matcher(text).find()) {
|
||||
targetFile = path;
|
||||
content = text;
|
||||
toolName = functionName; // Use baseName for subsequent replacements
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetFile == null) {
|
||||
throw new Exception("소스 코드를 찾을 수 없습니다: " + toolName);
|
||||
}
|
||||
|
||||
// 3. Update @McpTool group
|
||||
if (domainGroup != null && !domainGroup.trim().isEmpty()) {
|
||||
Pattern groupPattern = Pattern.compile("(@McpTool\\s*\\([^)]*group\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
|
||||
Matcher groupMatcher = groupPattern.matcher(content);
|
||||
if (groupMatcher.find()) {
|
||||
content = groupMatcher.replaceFirst("$1" + domainGroup + "$3");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update @McpFunction description
|
||||
if (description != null) {
|
||||
Pattern funcPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*description\\s*=\\s*\")([^\"]+)(\")", Pattern.DOTALL);
|
||||
Matcher funcMatcher = funcPattern.matcher(content);
|
||||
if (funcMatcher.find()) {
|
||||
content = funcMatcher.replaceFirst("$1" + description.replace("\\", "\\\\").replace("$", "\\\\$") + "$3");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update register flag
|
||||
Pattern regPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*register\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
|
||||
Matcher regMatcher = regPattern.matcher(content);
|
||||
if (regMatcher.find()) {
|
||||
content = regMatcher.replaceFirst("$1" + register + "$3");
|
||||
} else {
|
||||
Pattern addRegPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
|
||||
Matcher addRegMatcher = addRegPattern.matcher(content);
|
||||
if (addRegMatcher.find()) {
|
||||
content = addRegMatcher.replaceFirst("$1, register = " + register);
|
||||
}
|
||||
}
|
||||
|
||||
// 5.5 Update requiresApproval flag
|
||||
if (requiresApproval != null) {
|
||||
Pattern appPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\"[^)]*requiresApproval\\s*=\\s*)(true|false)([^a-zA-Z0-9])", Pattern.DOTALL);
|
||||
Matcher appMatcher = appPattern.matcher(content);
|
||||
if (appMatcher.find()) {
|
||||
content = appMatcher.replaceFirst("$1" + requiresApproval + "$3");
|
||||
} else {
|
||||
Pattern addAppPattern = Pattern.compile("(@McpFunction\\s*\\([^)]*name\\s*=\\s*\"" + Pattern.quote(toolName) + "\")", Pattern.DOTALL);
|
||||
Matcher addAppMatcher = addAppPattern.matcher(content);
|
||||
if (addAppMatcher.find()) {
|
||||
content = addAppMatcher.replaceFirst("$1, requiresApproval = " + requiresApproval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Write back to file
|
||||
Files.writeString(targetFile, content);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package io.shinhanlife.glow;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.glow
|
||||
* @className BizException
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public class BizException extends RuntimeException {
|
||||
public BizException(String s) {
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package io.shinhanlife.glow;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface GlowLogTarget {
|
||||
|
||||
Target[] value() default {};
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.glow
|
||||
* @className Target
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
enum Target {
|
||||
FILE, CONSOLE
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY gradlew .
|
||||
COPY gradle gradle
|
||||
COPY build.gradle settings.gradle ./
|
||||
COPY dap-common dap-common
|
||||
COPY dap-gateway dap-gateway
|
||||
RUN chmod +x gradlew
|
||||
RUN ./gradlew :dap-gateway:build -x test
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Asia/Seoul
|
||||
COPY --from=builder /app/dap-gateway/build/libs/*-SNAPSHOT.jar app.jar
|
||||
COPY dap-gateway/build/libs/*-SNAPSHOT.jar app.jar
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
plugins {
|
||||
// Gateway를 독립 실행 가능한 Spring Boot JAR로 생성합니다.
|
||||
id 'org.springframework.boot'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':dap-common')
|
||||
// Tool Registry, MCP 공통 처리, 보안/Schema 유틸리티를 공통 라이브러리에서 가져옵니다.
|
||||
implementation project(':dap-was-lib')
|
||||
|
||||
// Gateway REST API와 관리 화면의 HTTP 요청을 처리합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
|
||||
// 등록된 Excel 양식을 보존하면서 Tool 문서를 생성합니다.
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// Tool Registry 및 분산 캐시 연동에 사용합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// Spring AI MCP Server
|
||||
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
||||
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||
// MyBatis & DB
|
||||
// Tool/Registry 관련 DB 조회와 MyBatis Mapper 실행에 사용합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
||||
|
||||
// Gateway가 /mcp Endpoint를 MCP Server로 노출하도록 지원합니다.
|
||||
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
||||
|
||||
// ChatClient를 통해 OpenAI/OpenRouter 호환 모델을 호출합니다.
|
||||
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||
|
||||
// 로컬 개발·테스트용 인메모리 DB입니다. 운영 DB에는 사용하지 않습니다.
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
|
||||
// SQL 로그를 확인하기 위한 JDBC 프록시입니다.
|
||||
implementation 'p6spy:p6spy:3.9.1'
|
||||
|
||||
// MapStruct
|
||||
implementation "org.mapstruct:mapstruct:1.5.5.Final"
|
||||
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"
|
||||
annotationProcessor "org.mapstruct:mapstruct-processor:1.5.5.Final"
|
||||
|
||||
// implementation 'com.networknt:json-schema-validator:1.4.0' // Spring AI 내장 버전과 충돌 방지를 위해 주석 처리
|
||||
// Gateway REST API 문서와 Swagger UI를 제공합니다.
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = {"io.shinhanlife.dap.biz.mcp.gateway", "io.shinhanlife.dap.common.mcp", "io.shinhanlife.dap.common.config"})
|
||||
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dap.biz.mcp.gateway", "io.shinhanlife.dap.common.mcp", "io.shinhanlife.dap.common.config"})
|
||||
@EnableCaching
|
||||
public class DapGatewayApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DapGatewayApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.guardrail;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.presentation;
|
||||
|
||||
import io.shinhanlife.dap.common.util.PodScaffolder;
|
||||
import io.shinhanlife.dap.common.util.ToolScaffolder;
|
||||
import io.shinhanlife.dap.common.util.ToolSourceUpdater;
|
||||
import java.io.File;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/scaffold")
|
||||
public class ScaffoldingController {
|
||||
|
||||
@PostMapping("/pod")
|
||||
public String scaffoldPod(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String moduleName = req.getOrDefault("moduleName", "dap-tool-other");
|
||||
if (!moduleName.startsWith("dap-tool-")) moduleName = "dap-tool-" + moduleName;
|
||||
String port = req.getOrDefault("port", "8085");
|
||||
String shortName = moduleName.replace("dap-tool-", "").replace("-", "");
|
||||
String author = req.get("author");
|
||||
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
|
||||
String date = req.get("date");
|
||||
if (date == null || date.trim().isEmpty()) date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
|
||||
|
||||
return PodScaffolder.scaffoldPod(moduleName, port, shortName, author, date);
|
||||
} catch (Exception e) {
|
||||
return "오류 발생: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool")
|
||||
public String scaffoldTool(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String baseName = req.get("baseName");
|
||||
String interfaceId = req.get("interfaceId");
|
||||
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-other");
|
||||
String author = req.get("author");
|
||||
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
|
||||
String date = req.get("date");
|
||||
if (date == null || date.trim().isEmpty()) date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
|
||||
boolean register = Boolean.parseBoolean(req.getOrDefault("register", "true"));
|
||||
|
||||
return ToolScaffolder.scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, date, register);
|
||||
} catch (Exception e) {
|
||||
return "오류 발생: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool/update")
|
||||
public String updateTool(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String toolName = req.get("toolName");
|
||||
String domainGroup = req.getOrDefault("categoryKey", req.get("domainGroup"));
|
||||
String description = req.get("description");
|
||||
boolean register = Boolean.parseBoolean(req.getOrDefault("register", "true"));
|
||||
Boolean requiresApproval = req.containsKey("requiresApproval") ? Boolean.parseBoolean(req.get("requiresApproval")) : null;
|
||||
|
||||
ToolSourceUpdater.updateToolSource(toolName, domainGroup, description, register, requiresApproval);
|
||||
return "성공";
|
||||
} catch (Exception e) {
|
||||
return "오류 발생: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/modules")
|
||||
public List<String> listModules() {
|
||||
try {
|
||||
String sourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
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"));
|
||||
|
||||
if (files == null) return List.of("dap-tool-other");
|
||||
|
||||
return Arrays.stream(files).map(File::getName).sorted().collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
return List.of("dap-tool-other");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
|
||||
/**
|
||||
* Circuit Breaker의 현재 상태입니다.
|
||||
*
|
||||
* CLOSED는 정상 호출 가능, OPEN은 호출 차단, HALF_OPEN은 복구 확인 상태를 의미합니다.
|
||||
*/
|
||||
public enum CircuitBreakerState {
|
||||
CLOSED,
|
||||
OPEN,
|
||||
HALF_OPEN
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.transport;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.FailureType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.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 java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@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) {
|
||||
try {
|
||||
Object httpResult = restClient.post()
|
||||
.uri(targetUrl)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
// TODO: Use actual tenant's key
|
||||
.header("X-Trace-Id", UUID.randomUUID().toString())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode extractData(JsonNode root) {
|
||||
if (!root.path("success").asBoolean(true)) {
|
||||
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText());
|
||||
}
|
||||
return root.has("data") ? root.get("data") : root;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.transport;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tool 서버 호출 transport의 최소 공통 인터페이스입니다.
|
||||
*/
|
||||
public interface ToolInvoker {
|
||||
JsonNode invoke(Map<String, Object> payload, String targetUrl);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.converter;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSaveInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSearchInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSaveRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchResponse;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.RoleListRequest;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.converter
|
||||
* @className AccessMgmtConverter
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
public abstract class AccessMgmtConverter {
|
||||
|
||||
public abstract RoleListInDto toInDto(RoleListRequest request);
|
||||
|
||||
public abstract AthrSearchInDto toInDto(AthrSearchRequest request);
|
||||
|
||||
public abstract AthrSaveInDto toInDto(AthrSaveRequest request);
|
||||
|
||||
public abstract AthrSearchResponse toResponse(AthrOutDto outDto);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.domain.repository;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrItemOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSearchInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleKnwlAthrInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleToolAthrInDto;
|
||||
import io.shinhanlife.glow.GlowMybatisMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.domain.repository
|
||||
* @className AccessMgmtMapper
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@GlowMybatisMapper
|
||||
public interface AccessMgmtMapper {
|
||||
|
||||
List<RoleListOutDto> selectRoles(RoleListInDto inDto);
|
||||
|
||||
List<AthrItemOutDto> selectAllTools();
|
||||
|
||||
List<AthrItemOutDto> selectAllKnwls();
|
||||
|
||||
List<String> selectGrantedToolIds(AthrSearchInDto inDto);
|
||||
|
||||
List<String> selectGrantedKnwlIds(AthrSearchInDto inDto);
|
||||
|
||||
void deleteToolAthr(@Param("systId") String systId, @Param("roleNo") String roleNo);
|
||||
|
||||
void insertToolAthr(RoleToolAthrInDto inDto);
|
||||
|
||||
void deleteKnwlAthr(@Param("systId") String systId, @Param("roleNo") String roleNo);
|
||||
|
||||
void insertKnwlAthr(RoleKnwlAthrInDto inDto);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.domain.service;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSaveInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSearchInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListOutDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.domain.service
|
||||
* @className AccessMgmtService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface AccessMgmtService {
|
||||
|
||||
List<RoleListOutDto> getRoles(RoleListInDto inDto);
|
||||
|
||||
AthrOutDto getAthr(AthrSearchInDto inDto);
|
||||
|
||||
void saveAthr(AthrSaveInDto inDto);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.domain.service.impl;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.domain.repository.AccessMgmtMapper;
|
||||
import io.shinhanlife.dap.biz.so.atm.domain.service.AccessMgmtService;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrItemOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSaveInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrSearchInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleKnwlAthrInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListInDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleToolAthrInDto;
|
||||
import io.shinhanlife.dap.common.util.SessionUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.domain.service.impl
|
||||
* @className AccessMgmtServiceImpl
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccessMgmtServiceImpl implements AccessMgmtService {
|
||||
|
||||
private final AccessMgmtMapper accessMgmtMapper;
|
||||
|
||||
private static final String SYSTEM_CD = "AXH";
|
||||
private static final String SYSTEM_PRG = "SOATM0100";
|
||||
|
||||
@Override
|
||||
public List<RoleListOutDto> getRoles(RoleListInDto inDto) {
|
||||
return accessMgmtMapper.selectRoles(inDto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AthrOutDto getAthr(AthrSearchInDto inDto) {
|
||||
Set<String> grantedToolIds = accessMgmtMapper.selectGrantedToolIds(inDto)
|
||||
.stream().collect(Collectors.toSet());
|
||||
|
||||
Set<String> grantedKnwlIds = accessMgmtMapper.selectGrantedKnwlIds(inDto)
|
||||
.stream().collect(Collectors.toSet());
|
||||
|
||||
List<AthrItemOutDto> tools = accessMgmtMapper.selectAllTools()
|
||||
.stream()
|
||||
.peek(item -> item.setGranted(grantedToolIds.contains(item.getResourceId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<AthrItemOutDto> knwls = accessMgmtMapper.selectAllKnwls()
|
||||
.stream()
|
||||
.peek(item -> item.setGranted(grantedKnwlIds.contains(item.getResourceId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new AthrOutDto(tools, knwls);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveAthr(AthrSaveInDto inDto) {
|
||||
Date now = new Date();
|
||||
String systId = inDto.getSystId();
|
||||
String roleNo = inDto.getRoleNo();
|
||||
|
||||
accessMgmtMapper.deleteToolAthr(systId, roleNo);
|
||||
if (inDto.getGrantedToolIds() != null) {
|
||||
for (String toolId : inDto.getGrantedToolIds()) {
|
||||
accessMgmtMapper.insertToolAthr(buildToolAthrInDto(systId, roleNo, toolId, now));
|
||||
}
|
||||
}
|
||||
|
||||
accessMgmtMapper.deleteKnwlAthr(systId, roleNo);
|
||||
if (inDto.getGrantedKnwlIds() != null) {
|
||||
for (String knwlId : inDto.getGrantedKnwlIds()) {
|
||||
accessMgmtMapper.insertKnwlAthr(buildKnwlAthrInDto(systId, roleNo, knwlId, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RoleToolAthrInDto buildToolAthrInDto(String systId, String roleNo, String toolId, Date now) {
|
||||
RoleToolAthrInDto dto = new RoleToolAthrInDto();
|
||||
dto.setRoleToolAthrId("RTA-" + shortUuid());
|
||||
dto.setSystId(systId);
|
||||
dto.setRoleNo(roleNo);
|
||||
dto.setToolId(toolId);
|
||||
dto.setPuseYn("Y");
|
||||
fillAudit(dto, now);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private RoleKnwlAthrInDto buildKnwlAthrInDto(String systId, String roleNo, String knwlId, Date now) {
|
||||
RoleKnwlAthrInDto dto = new RoleKnwlAthrInDto();
|
||||
dto.setRoleKnwlAthrId("RKA-" + shortUuid());
|
||||
dto.setSystId(systId);
|
||||
dto.setRoleNo(roleNo);
|
||||
dto.setKnwlId(knwlId);
|
||||
dto.setPuseYn("Y");
|
||||
fillAudit(dto, now);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void fillAudit(RoleToolAthrInDto dto, Date now) {
|
||||
String prafNo = SessionUtil.getPrafNo();
|
||||
String ognzNo = SessionUtil.getOgnzNo();
|
||||
dto.setSystRgiDt(now); dto.setSystRgiPrafNo(prafNo);
|
||||
dto.setSystRgiOgnzNo(ognzNo); dto.setSystRgiSystCd(SYSTEM_CD);
|
||||
dto.setSystRgiPrgrId(SYSTEM_PRG);
|
||||
dto.setSystChgDt(now); dto.setSystChgPrafNo(prafNo);
|
||||
dto.setSystChgOgnzNo(ognzNo); dto.setSystChgSystCd(SYSTEM_CD);
|
||||
dto.setSystChgPrgrId(SYSTEM_PRG);
|
||||
}
|
||||
|
||||
private void fillAudit(RoleKnwlAthrInDto dto, Date now) {
|
||||
String prafNo = SessionUtil.getPrafNo();
|
||||
String ognzNo = SessionUtil.getOgnzNo();
|
||||
dto.setSystRgiDt(now); dto.setSystRgiPrafNo(prafNo);
|
||||
dto.setSystRgiOgnzNo(ognzNo); dto.setSystRgiSystCd(SYSTEM_CD);
|
||||
dto.setSystRgiPrgrId(SYSTEM_PRG);
|
||||
dto.setSystChgDt(now); dto.setSystChgPrafNo(prafNo);
|
||||
dto.setSystChgOgnzNo(ognzNo); dto.setSystChgSystCd(SYSTEM_CD);
|
||||
dto.setSystChgPrgrId(SYSTEM_PRG);
|
||||
}
|
||||
|
||||
private String shortUuid() {
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className AthrItemOutDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AthrItemOutDto {
|
||||
private String resourceId;
|
||||
private String resourceNm;
|
||||
private String resourceDs;
|
||||
private String typeCode;
|
||||
private boolean granted;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Setter;
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className AthrOutDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
public class AthrOutDto {
|
||||
private List<AthrItemOutDto> tools;
|
||||
private List<AthrItemOutDto> knwls;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className AthrSaveInDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AthrSaveInDto {
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
private List<String> grantedToolIds;
|
||||
private List<String> grantedKnwlIds;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className AthrSearchInDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AthrSearchInDto {
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className RoleKnwlAthrInDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RoleKnwlAthrInDto {
|
||||
private String roleKnwlAthrId;
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
private String knwlId;
|
||||
private String puseYn;
|
||||
|
||||
private Date systRgiDt;
|
||||
private String systRgiPrafNo;
|
||||
private String systRgiOgnzNo;
|
||||
private String systRgiSystCd;
|
||||
private String systRgiPrgrId;
|
||||
private Date systChgDt;
|
||||
private String systChgPrafNo;
|
||||
private String systChgOgnzNo;
|
||||
private String systChgSystCd;
|
||||
private String systChgPrgrId;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className RoleListInDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RoleListInDto {
|
||||
private String systId;
|
||||
private String puseYn;
|
||||
private String keyword;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className RoleListOutDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RoleListOutDto {
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
private String roleNm;
|
||||
private String roleDs;
|
||||
private String puseYn;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.dto
|
||||
* @className RoleToolAthrInDto
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RoleToolAthrInDto {
|
||||
private String roleToolAthrId;
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
private String toolId;
|
||||
private String puseYn;
|
||||
|
||||
private Date systRgiDt;
|
||||
private String systRgiPrafNo;
|
||||
private String systRgiOgnzNo;
|
||||
private String systRgiSystCd;
|
||||
private String systRgiPrgrId;
|
||||
private Date systChgDt;
|
||||
private String systChgPrafNo;
|
||||
private String systChgOgnzNo;
|
||||
private String systChgSystCd;
|
||||
private String systChgPrgrId;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.presentation;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSaveRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchResponse;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.RoleListRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.usecase.AccessMgmtUseCase;
|
||||
import io.shinhanlife.glow.BaseResponse;
|
||||
import io.shinhanlife.glow.ResponseUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.presentation
|
||||
* @className SOATM0100Controller
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/so/atm")
|
||||
@RequiredArgsConstructor
|
||||
public class SOATM0100Controller {
|
||||
|
||||
private final AccessMgmtUseCase accessMgmtUseCase;
|
||||
|
||||
@PostMapping("/SOATM0100R")
|
||||
public ResponseEntity<BaseResponse<List<RoleListOutDto>>> getRoles(
|
||||
@RequestBody RoleListRequest request) {
|
||||
return ResponseUtil.ok(accessMgmtUseCase.getRoles(request));
|
||||
}
|
||||
|
||||
@PostMapping("/SOATM0101R")
|
||||
public ResponseEntity<BaseResponse<AthrSearchResponse>> getAthr(
|
||||
@RequestBody AthrSearchRequest request) {
|
||||
return ResponseUtil.ok(accessMgmtUseCase.getAthr(request));
|
||||
}
|
||||
|
||||
@PostMapping("/SOATM0100U")
|
||||
public ResponseEntity<BaseResponse<Void>> saveAthr(
|
||||
@RequestBody AthrSaveRequest request) {
|
||||
accessMgmtUseCase.saveAthr(request);
|
||||
return ResponseUtil.ok();
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.presentation.io;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.presentation.io
|
||||
* @className AthrSaveRequest
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class AthrSaveRequest {
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
private List<String> grantedToolIds;
|
||||
private List<String> grantedKnwlIds;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.presentation.io;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.presentation.io
|
||||
* @className AthrSearchRequest
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class AthrSearchRequest {
|
||||
private String systId;
|
||||
private String roleNo;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.presentation.io;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.AthrItemOutDto;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.presentation.io
|
||||
* @className AthrSearchResponse
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class AthrSearchResponse {
|
||||
private List<AthrItemOutDto> tools;
|
||||
private List<AthrItemOutDto> knwls;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.presentation.io;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.presentation.io
|
||||
* @className RoleListRequest
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class RoleListRequest {
|
||||
private String systId;
|
||||
private String puseYn;
|
||||
private String keyword;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.usecase;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSaveRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchResponse;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.RoleListRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.usecase
|
||||
* @className AccessMgmtUseCase
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface AccessMgmtUseCase {
|
||||
|
||||
List<RoleListOutDto> getRoles(RoleListRequest request);
|
||||
|
||||
AthrSearchResponse getAthr(AthrSearchRequest request);
|
||||
|
||||
void saveAthr(AthrSaveRequest request);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package io.shinhanlife.dap.biz.so.atm.usecase.impl;
|
||||
|
||||
import io.shinhanlife.dap.biz.so.atm.converter.AccessMgmtConverter;
|
||||
import io.shinhanlife.dap.biz.so.atm.domain.service.AccessMgmtService;
|
||||
import io.shinhanlife.dap.biz.so.atm.dto.RoleListOutDto;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSaveRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.AthrSearchResponse;
|
||||
import io.shinhanlife.dap.biz.so.atm.presentation.io.RoleListRequest;
|
||||
import io.shinhanlife.dap.biz.so.atm.usecase.AccessMgmtUseCase;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.so.atm.usecase.impl
|
||||
* @className AccessMgmtUseCaseImpl
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccessMgmtUseCaseImpl implements AccessMgmtUseCase {
|
||||
|
||||
private final AccessMgmtService accessMgmtService;
|
||||
private final AccessMgmtConverter converter;
|
||||
|
||||
@Override
|
||||
public List<RoleListOutDto> getRoles(RoleListRequest request) {
|
||||
return accessMgmtService.getRoles(converter.toInDto(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AthrSearchResponse getAthr(AthrSearchRequest request) {
|
||||
return converter.toResponse(accessMgmtService.getAthr(converter.toInDto(request)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAthr(AthrSaveRequest request) {
|
||||
accessMgmtService.saveAthr(converter.toInDto(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.shinhanlife.dap.mcg;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg
|
||||
* @className DapGatewayApplication
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = {"io.shinhanlife.dap.mcg", "io.shinhanlife.dap.lib.mcp", "io.shinhanlife.dap.lib.config"})
|
||||
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dap.mcg", "io.shinhanlife.dap.lib.mcp", "io.shinhanlife.dap.lib.config"})
|
||||
@EnableCaching
|
||||
@EnableScheduling
|
||||
public class DapGatewayApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DapGatewayApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.aop;
|
||||
package io.shinhanlife.dap.mcg.aop;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
@@ -8,16 +8,16 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StopWatch;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.aop
|
||||
* @package io.shinhanlife.dap.mcg.aop
|
||||
* @className GatewayLoggingAspect
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ import org.springframework.util.StopWatch;
|
||||
public class GatewayLoggingAspect {
|
||||
|
||||
// gateway의 controller 패키지 하위의 모든 클래스/메서드 실행 시 작동
|
||||
@Around("execution(* io.shinhanlife.dap.biz.mcp.gateway.presentation..*(..))")
|
||||
@Around("execution(* io.shinhanlife.dap.mcg.presentation..*(..))")
|
||||
public Object logGatewayExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
String targetMethod = joinPoint.getSignature().toShortString();
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
@@ -1,8 +1,22 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.audit;
|
||||
package io.shinhanlife.dap.mcg.audit;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.guardrail.SensitiveDataMasker;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.security.McpRequestContext;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.audit
|
||||
* @className AuditLogService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.mcg.security.McpRequestContext;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -17,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.config;
|
||||
package io.shinhanlife.dap.mcg.config;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.config
|
||||
* @className AgentResponseBudgetProperties
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.config;
|
||||
package io.shinhanlife.dap.mcg.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@@ -8,16 +8,16 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.config
|
||||
* @package io.shinhanlife.dap.mcg.config
|
||||
* @className GatewayFallbackProperties
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.config;
|
||||
package io.shinhanlife.dap.mcg.config;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.config
|
||||
* @className McpGatewayProperties
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -10,7 +25,7 @@ import java.util.stream.Collectors;
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "mcp")
|
||||
/**
|
||||
* application.properties의 mcp.* 설정을 Java 코드에서 사용하기 위한 설정 클래스입니다.
|
||||
* application.yml의 mcp.* 설정을 Java 코드에서 사용하기 위한 설정 클래스입니다.
|
||||
*
|
||||
* API Key, 허용 Tool, 감사 로그, Redis Trace, Retry, Circuit Breaker,
|
||||
* EIMS/Tool 서버 주소 같은 MCP Gateway 운영 설정을 한 곳에서 관리합니다.
|
||||
@@ -1,6 +1,6 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.config;
|
||||
package io.shinhanlife.dap.mcg.config;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
@@ -10,16 +10,16 @@ import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.config
|
||||
* @package io.shinhanlife.dap.mcg.config
|
||||
* @className RedisConfig
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
|
||||
public record DocumentGenerationRequest(
|
||||
ToolMetadata tool,
|
||||
String version,
|
||||
boolean includeProgram,
|
||||
boolean includeProcess,
|
||||
boolean includeRevision) {
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/mcp/api/v1/admin/documents")
|
||||
public class DocumentGeneratorController {
|
||||
|
||||
private static final MediaType XLSX = MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
|
||||
private final DocumentGeneratorService documentGeneratorService;
|
||||
|
||||
public DocumentGeneratorController(DocumentGeneratorService documentGeneratorService) {
|
||||
this.documentGeneratorService = documentGeneratorService;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/program", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> generateProgram(@RequestBody DocumentGenerationRequest request) {
|
||||
return download(documentGeneratorService.generateProgram(request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/interface", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> generateInterface(@RequestBody DocumentGenerationRequest request) {
|
||||
return download(documentGeneratorService.generateInterface(request));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> invalidRequest(IllegalArgumentException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> download(GeneratedDocument document) {
|
||||
ContentDisposition disposition = ContentDisposition.attachment()
|
||||
.filename(document.fileName(), StandardCharsets.UTF_8)
|
||||
.build();
|
||||
return ResponseEntity.ok()
|
||||
.contentType(XLSX)
|
||||
.contentLength(document.content().length)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
|
||||
.body(document.content());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.ss.util.CellReference;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DocumentGeneratorService {
|
||||
|
||||
static final String PROGRAM_TEMPLATE = "document-templates/excel/program-definition-template.xlsx";
|
||||
static final String INTERFACE_TEMPLATE = "document-templates/excel/interface-definition-template.xlsx";
|
||||
|
||||
private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.BASIC_ISO_DATE;
|
||||
private static final DateTimeFormatter DISPLAY_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
private static final Pattern PATTERN_LENGTH = Pattern.compile("\\\\d\\{(\\d+)}");
|
||||
private static final int INTERFACE_FIRST_ROW = 8;
|
||||
private static final int INTERFACE_LAST_ROW = 30;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public DocumentGeneratorService(ObjectMapper objectMapper) {
|
||||
this(objectMapper, Clock.system(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
|
||||
DocumentGeneratorService(ObjectMapper objectMapper, Clock clock) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public GeneratedDocument generateProgram(DocumentGenerationRequest request) {
|
||||
ToolMetadata tool = validate(request);
|
||||
if (!request.includeProgram() && !request.includeProcess() && !request.includeRevision()) {
|
||||
throw new IllegalArgumentException("프로그램 문서에서 한 개 이상의 시트를 선택하세요.");
|
||||
}
|
||||
|
||||
String version = normalizeVersion(request.version());
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
try (InputStream input = resource(PROGRAM_TEMPLATE);
|
||||
Workbook workbook = WorkbookFactory.create(input)) {
|
||||
|
||||
Sheet programSheet = requiredSheet(workbook, "프로그램정의서");
|
||||
Sheet designTemplate = requiredSheet(workbook, "입출력정의");
|
||||
Sheet revisionSheet = request.includeRevision()
|
||||
? workbook.cloneSheet(workbook.getSheetIndex(designTemplate))
|
||||
: null;
|
||||
|
||||
if (request.includeProgram()) {
|
||||
populateProgramSheet(programSheet, tool, version, today);
|
||||
} else {
|
||||
workbook.removeSheetAt(workbook.getSheetIndex(programSheet));
|
||||
}
|
||||
|
||||
if (request.includeProcess()) {
|
||||
workbook.setSheetName(workbook.getSheetIndex(designTemplate), "처리설계");
|
||||
populateProcessSheet(designTemplate, tool);
|
||||
} else {
|
||||
workbook.removeSheetAt(workbook.getSheetIndex(designTemplate));
|
||||
}
|
||||
|
||||
if (revisionSheet != null) {
|
||||
workbook.setSheetName(workbook.getSheetIndex(revisionSheet), "개정이력");
|
||||
populateRevisionSheet(revisionSheet, version, today);
|
||||
}
|
||||
|
||||
workbook.setActiveSheet(0);
|
||||
return output(workbook, programFileName(tool, version, today));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("프로그램정의서 Excel 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public GeneratedDocument generateInterface(DocumentGenerationRequest request) {
|
||||
ToolMetadata tool = validate(request);
|
||||
String version = normalizeVersion(request.version());
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
try (InputStream input = resource(INTERFACE_TEMPLATE);
|
||||
Workbook workbook = WorkbookFactory.create(input)) {
|
||||
|
||||
populateInterfaceSheet(requiredSheet(workbook, "Request In"), tool, false);
|
||||
populateInterfaceSheet(requiredSheet(workbook, "Response Out"), tool, true);
|
||||
workbook.setActiveSheet(0);
|
||||
return output(workbook, interfaceFileName(tool, version, today));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("인터페이스정의서 Excel 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void populateProgramSheet(Sheet sheet, ToolMetadata tool, String version, LocalDate today) {
|
||||
String label = label(tool);
|
||||
set(sheet, "B4", documentId(tool));
|
||||
set(sheet, "E4", version);
|
||||
set(sheet, "H4", DISPLAY_DATE.format(today));
|
||||
set(sheet, "B5", label);
|
||||
set(sheet, "E5", value(tool.getName()));
|
||||
set(sheet, "H5", "생성 완료");
|
||||
set(sheet, "B6", value(tool.getCategoryKey()));
|
||||
set(sheet, "E6", "AX HUB MCP Gateway");
|
||||
set(sheet, "H6", "자동 생성");
|
||||
set(sheet, "B9", defaultValue(tool.getDescription(), "설명이 등록되지 않은 Tool입니다."));
|
||||
set(sheet, "B10", label);
|
||||
set(sheet, "E10", tool.getOperationType() == null ? "-" : tool.getOperationType().name());
|
||||
set(sheet, "H10", Boolean.FALSE.equals(tool.getVisible()) ? "비공개" : "공개");
|
||||
set(sheet, "B11", schemaFields(tool.getParametersSchema()).size()
|
||||
+ "개 파라미터가 Tool JSON 스키마에서 자동 매핑되었습니다.");
|
||||
set(sheet, "B14", value(tool.getCategoryKey()));
|
||||
set(sheet, "E14", value(tool.getPodUrl()));
|
||||
set(sheet, "H14", value(tool.getIntegrationType()));
|
||||
set(sheet, "B15", value(tool.getEndpoint()));
|
||||
set(sheet, "E15", value(tool.getMciServiceId()));
|
||||
set(sheet, "H15", formatTimeout(tool.getTimeoutMillis()));
|
||||
set(sheet, "B16", yesNo(tool.getRequiresApproval()));
|
||||
set(sheet, "E16", yesNo(tool.getReadOnlyHint()));
|
||||
set(sheet, "H16", yesNo(tool.getIdempotentHint()));
|
||||
set(sheet, "B21", label);
|
||||
set(sheet, "C21", "Tool 입력 스키마에 따라 요청 파라미터를 검증합니다.");
|
||||
set(sheet, "B22", defaultValue(tool.getIntegrationType(), "REST"));
|
||||
set(sheet, "H22", value(tool.getMciServiceId()));
|
||||
set(sheet, "A26", "※ ToolMetadata를 기준으로 자동 생성된 문서입니다. 업무 규칙과 승인 정보는 담당자 검토가 필요합니다.");
|
||||
}
|
||||
|
||||
private void populateProcessSheet(Sheet sheet, ToolMetadata tool) {
|
||||
CellStyle title = style(sheet, "A1");
|
||||
CellStyle section = style(sheet, "A2");
|
||||
CellStyle header = style(sheet, "A3");
|
||||
CellStyle data = style(sheet, "A4");
|
||||
CellStyle note = style(sheet, "A9");
|
||||
resetSheet(sheet, 9, 9);
|
||||
|
||||
mergeSet(sheet, "A1:I1", "처리설계", title);
|
||||
mergeSet(sheet, "A2:I2", label(tool) + " · 공통 처리 절차", section);
|
||||
setStyled(sheet, 2, 0, "단계", header);
|
||||
setStyled(sheet, 2, 1, "처리 주체", header);
|
||||
mergeSet(sheet, "C3:F3", "처리 내용", header);
|
||||
mergeSet(sheet, "G3:H3", "성공 조건", header);
|
||||
setStyled(sheet, 2, 8, "비고", header);
|
||||
|
||||
List<List<String>> steps = List.of(
|
||||
List.of("1", "Gateway", "호출자 인증과 Tool 실행 권한을 확인합니다.", "권한 검증 성공", "공통 처리"),
|
||||
List.of("2", label(tool), "Tool 입력 스키마에 따라 요청 파라미터를 검증합니다.", "스키마 검증 성공", "자동 생성"),
|
||||
List.of("3", defaultValue(tool.getIntegrationType(), "REST"), "등록된 엔드포인트 또는 서비스 ID로 대상 시스템을 호출합니다.", "정상 응답 수신", value(tool.getMciServiceId())),
|
||||
List.of("4", "Gateway", "응답을 MCP 표준 결과로 변환하고 정책을 검사합니다.", "응답 정책 통과", "응답 정책"),
|
||||
List.of("5", "Gateway", "감사 로그를 기록하고 호출자에게 결과를 반환합니다.", "응답 전송 완료", "추적 ID 포함")
|
||||
);
|
||||
for (int index = 0; index < steps.size(); index++) {
|
||||
int row = 3 + index;
|
||||
List<String> step = steps.get(index);
|
||||
setStyled(sheet, row, 0, step.get(0), data);
|
||||
setStyled(sheet, row, 1, step.get(1), data);
|
||||
mergeSet(sheet, "C" + (row + 1) + ":F" + (row + 1), step.get(2), data);
|
||||
mergeSet(sheet, "G" + (row + 1) + ":H" + (row + 1), step.get(3), data);
|
||||
setStyled(sheet, row, 8, step.get(4), data);
|
||||
sheet.getRow(row).setHeightInPoints(38);
|
||||
}
|
||||
mergeSet(sheet, "A9:I9", "※ 공통 처리 흐름은 ToolMetadata와 Gateway 정책을 기준으로 자동 작성되었습니다.", note);
|
||||
}
|
||||
|
||||
private void populateRevisionSheet(Sheet sheet, String version, LocalDate today) {
|
||||
CellStyle title = style(sheet, "A1");
|
||||
CellStyle section = style(sheet, "A2");
|
||||
CellStyle header = style(sheet, "A3");
|
||||
CellStyle data = style(sheet, "A4");
|
||||
CellStyle note = style(sheet, "A9");
|
||||
resetSheet(sheet, 9, 9);
|
||||
|
||||
mergeSet(sheet, "A1:I1", "개정이력", title);
|
||||
mergeSet(sheet, "A2:I2", "문서 버전 및 변경 내역", section);
|
||||
setStyled(sheet, 2, 0, "버전", header);
|
||||
setStyled(sheet, 2, 1, "작성일", header);
|
||||
mergeSet(sheet, "C3:D3", "작성자", header);
|
||||
mergeSet(sheet, "E3:H3", "변경 내용", header);
|
||||
setStyled(sheet, 2, 8, "비고", header);
|
||||
|
||||
setStyled(sheet, 3, 0, version, data);
|
||||
setStyled(sheet, 3, 1, DISPLAY_DATE.format(today), data);
|
||||
mergeSet(sheet, "C4:D4", "Document Generator", data);
|
||||
mergeSet(sheet, "E4:H4", "ToolMetadata 기준 최초 생성", data);
|
||||
setStyled(sheet, 3, 8, "자동 생성", data);
|
||||
mergeSet(sheet, "A6:I6", "※ 배포 전 담당자의 최종 검토가 필요합니다.", note);
|
||||
}
|
||||
|
||||
private void populateInterfaceSheet(Sheet sheet, ToolMetadata tool, boolean response) {
|
||||
String label = label(tool);
|
||||
String interfaceId = interfaceId(tool);
|
||||
set(sheet, "A1", label + " 인터페이스 설계서");
|
||||
set(sheet, "C2", label);
|
||||
set(sheet, "C3", defaultValue(tool.getDescription(), "설명이 등록되지 않은 Tool입니다."));
|
||||
set(sheet, "D4", value(tool.getEndpoint()));
|
||||
set(sheet, "D5", "운영 URL 확인 필요");
|
||||
set(sheet, "A7", interfaceId);
|
||||
set(sheet, "B7", response ? "데이터 수신시스템 · 응답 (Response Out)" : "데이터 송신시스템 · 요청 (Request In)");
|
||||
|
||||
clearInterfaceRows(sheet);
|
||||
if (response) {
|
||||
writeInterfaceField(sheet, INTERFACE_FIRST_ROW, interfaceId, "AX HUB\nMCP Gateway", "Body",
|
||||
interfaceId + "_O", new SchemaField("resultData", "object", "결과 데이터", "-", true, "", false));
|
||||
set(sheet, "C33", prettyJson(Map.of("resultData", Map.of("status", "SUCCESS"))));
|
||||
} else {
|
||||
List<SchemaField> fields = schemaFields(tool.getParametersSchema());
|
||||
if (fields.size() > INTERFACE_LAST_ROW - INTERFACE_FIRST_ROW + 1) {
|
||||
throw new IllegalArgumentException("인터페이스 양식은 최대 23개 요청 필드를 지원합니다.");
|
||||
}
|
||||
for (int index = 0; index < fields.size(); index++) {
|
||||
writeInterfaceField(sheet, INTERFACE_FIRST_ROW + index, interfaceId,
|
||||
defaultValue(tool.getIntegrationType(), "Tool"), "Body", interfaceId + "_I", fields.get(index));
|
||||
}
|
||||
set(sheet, "C33", prettyJson(exampleFromSchema(tool.getParametersSchema())));
|
||||
}
|
||||
set(sheet, "A34", "※ ToolMetadata를 기준으로 자동 생성된 검토용 문서입니다.");
|
||||
}
|
||||
|
||||
private void writeInterfaceField(Sheet sheet, int rowIndex, String interfaceId, String system, String level,
|
||||
String store, SchemaField field) {
|
||||
if (rowIndex == INTERFACE_FIRST_ROW) {
|
||||
setStyled(sheet, rowIndex, 0, interfaceId, style(sheet, "A9"));
|
||||
setStyled(sheet, rowIndex, 1, system, style(sheet, "B9"));
|
||||
setStyled(sheet, rowIndex, 2, level, style(sheet, "C9"));
|
||||
}
|
||||
setStyled(sheet, rowIndex, 3, store, style(sheet, "D9"));
|
||||
setStyled(sheet, rowIndex, 4, defaultValue(field.description(), field.path()), style(sheet, "E9"));
|
||||
setStyled(sheet, rowIndex, 5, field.path(), style(sheet, "F9"));
|
||||
setStyled(sheet, rowIndex, 6, field.type(), style(sheet, "G9"));
|
||||
setStyled(sheet, rowIndex, 7, field.length(), style(sheet, "H9"));
|
||||
setStyled(sheet, rowIndex, 8, "", style(sheet, "I9"));
|
||||
setStyled(sheet, rowIndex, 9, field.coded() ? "Y" : "N", style(sheet, "J9"));
|
||||
String note = (field.required() ? "필수 · " : "") + "ToolMetadata";
|
||||
if (!field.example().isBlank()) {
|
||||
note += " · 예시: " + field.example();
|
||||
}
|
||||
setStyled(sheet, rowIndex, 10, note, style(sheet, "K9"));
|
||||
}
|
||||
|
||||
private void clearInterfaceRows(Sheet sheet) {
|
||||
for (int rowIndex = INTERFACE_FIRST_ROW; rowIndex <= INTERFACE_LAST_ROW; rowIndex++) {
|
||||
for (int column = 3; column <= 10; column++) {
|
||||
setStyled(sheet, rowIndex, column, "", style(sheet, "D9"));
|
||||
}
|
||||
}
|
||||
set(sheet, "A9", "");
|
||||
set(sheet, "B9", "");
|
||||
set(sheet, "C9", "");
|
||||
}
|
||||
|
||||
private List<SchemaField> schemaFields(Map<String, Object> schema) {
|
||||
List<SchemaField> fields = new ArrayList<>();
|
||||
collectSchemaFields(schema, "", Set.of(), fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void collectSchemaFields(Map<String, Object> schema, String prefix, Set<String> inheritedRequired,
|
||||
List<SchemaField> fields) {
|
||||
if (schema == null) {
|
||||
return;
|
||||
}
|
||||
Object requiredValue = schema.get("required");
|
||||
Set<String> required = requiredValue instanceof Collection<?> values
|
||||
? values.stream().map(String::valueOf).collect(java.util.stream.Collectors.toSet())
|
||||
: inheritedRequired;
|
||||
Object propertiesValue = schema.get("properties");
|
||||
if (!(propertiesValue instanceof Map<?, ?> properties)) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : properties.entrySet()) {
|
||||
String name = String.valueOf(entry.getKey());
|
||||
if (!(entry.getValue() instanceof Map<?, ?> rawNode)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> node = (Map<String, Object>) rawNode;
|
||||
String path = prefix.isBlank() ? name : prefix + "." + name;
|
||||
String type = String.valueOf(node.getOrDefault("type", "string"));
|
||||
String description = value(node.get("description"));
|
||||
String length = schemaLength(node);
|
||||
String example = schemaExample(node);
|
||||
boolean coded = node.get("enum") instanceof Collection<?> values && !values.isEmpty();
|
||||
fields.add(new SchemaField(path, type, description, length, required.contains(name), example, coded));
|
||||
if ("object".equals(type)) {
|
||||
collectSchemaFields(node, path, Set.of(), fields);
|
||||
} else if ("array".equals(type) && node.get("items") instanceof Map<?, ?> items) {
|
||||
collectSchemaFields((Map<String, Object>) items, path + "[]", Set.of(), fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String schemaLength(Map<String, Object> node) {
|
||||
Object length = node.get("maxLength");
|
||||
if (length == null) {
|
||||
length = node.get("length");
|
||||
}
|
||||
if (length != null) {
|
||||
return String.valueOf(length);
|
||||
}
|
||||
Object pattern = node.get("pattern");
|
||||
if (pattern != null) {
|
||||
Matcher matcher = PATTERN_LENGTH.matcher(String.valueOf(pattern));
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
private String schemaExample(Map<String, Object> node) {
|
||||
Object example = node.get("example");
|
||||
if (example == null && node.get("examples") instanceof List<?> examples && !examples.isEmpty()) {
|
||||
example = examples.get(0);
|
||||
}
|
||||
if (example == null) {
|
||||
example = node.get("default");
|
||||
}
|
||||
return value(example);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object exampleFromSchema(Map<String, Object> schema) {
|
||||
if (schema == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Object type = schema.get("type");
|
||||
if ("object".equals(type) || schema.get("properties") instanceof Map<?, ?>) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
Object propertiesValue = schema.get("properties");
|
||||
if (propertiesValue instanceof Map<?, ?> properties) {
|
||||
for (Map.Entry<?, ?> entry : properties.entrySet()) {
|
||||
if (entry.getValue() instanceof Map<?, ?> node) {
|
||||
result.put(String.valueOf(entry.getKey()), exampleFromSchema((Map<String, Object>) node));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Object example = schema.get("example");
|
||||
if (example == null && schema.get("examples") instanceof List<?> examples && !examples.isEmpty()) {
|
||||
example = examples.get(0);
|
||||
}
|
||||
if (example == null) {
|
||||
example = schema.get("default");
|
||||
}
|
||||
if (example != null) {
|
||||
return example;
|
||||
}
|
||||
return switch (String.valueOf(type)) {
|
||||
case "integer", "number" -> 0;
|
||||
case "boolean" -> false;
|
||||
case "array" -> schema.get("items") instanceof Map<?, ?> items
|
||||
? List.of(exampleFromSchema((Map<String, Object>) items)) : List.of();
|
||||
default -> "<value>";
|
||||
};
|
||||
}
|
||||
|
||||
private String prettyJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("JSON 예시 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private GeneratedDocument output(Workbook workbook, String fileName) throws IOException {
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
workbook.write(output);
|
||||
return new GeneratedDocument(fileName, output.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream resource(String path) throws IOException {
|
||||
return new ClassPathResource(path).getInputStream();
|
||||
}
|
||||
|
||||
private ToolMetadata validate(DocumentGenerationRequest request) {
|
||||
if (request == null || request.tool() == null) {
|
||||
throw new IllegalArgumentException("ToolMetadata가 필요합니다.");
|
||||
}
|
||||
ToolMetadata tool = request.tool();
|
||||
if (isBlank(tool.getUid()) && isBlank(tool.getName())) {
|
||||
throw new IllegalArgumentException("Tool UID 또는 Tool 이름이 필요합니다.");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
private String programFileName(ToolMetadata tool, String version, LocalDate today) {
|
||||
return safeFilename(label(tool)) + "_프로그램정의서_v" + version + "_" + FILE_DATE.format(today) + ".xlsx";
|
||||
}
|
||||
|
||||
private String interfaceFileName(ToolMetadata tool, String version, LocalDate today) {
|
||||
return safeFilename(label(tool)) + "_인터페이스정의서_v" + version + "_" + FILE_DATE.format(today) + ".xlsx";
|
||||
}
|
||||
|
||||
private String normalizeVersion(String version) {
|
||||
String normalized = isBlank(version) ? "1.0" : version.trim().replaceFirst("^[vV]", "");
|
||||
if (!normalized.matches("[0-9A-Za-z._-]+")) {
|
||||
throw new IllegalArgumentException("버전은 영문, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String safeFilename(String value) {
|
||||
String result = defaultValue(value, "tool").replaceAll("[\\\\/:*?\"<>|]", "_").trim();
|
||||
return result.isEmpty() ? "tool" : result;
|
||||
}
|
||||
|
||||
private String label(ToolMetadata tool) {
|
||||
return defaultValue(tool.getDisplayName(), defaultValue(tool.getName(), tool.getUid()));
|
||||
}
|
||||
|
||||
private String documentId(ToolMetadata tool) {
|
||||
String category = defaultValue(tool.getCategoryKey(), "ETC").toUpperCase(Locale.ROOT);
|
||||
String uid = defaultValue(tool.getUid(), tool.getName()).replaceAll("[^0-9A-Za-z]", "");
|
||||
uid = uid.length() > 7 ? uid.substring(0, 7) : uid;
|
||||
return "AXHUB-FS-" + category + "-" + uid.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String interfaceId(ToolMetadata tool) {
|
||||
int hash = Objects.hash(tool.getUid(), tool.getName());
|
||||
return "AXHUB" + String.format(Locale.ROOT, "%05d", Math.floorMod(hash, 100_000));
|
||||
}
|
||||
|
||||
private String formatTimeout(Long timeout) {
|
||||
return timeout == null ? "-" : String.format(Locale.ROOT, "%,d ms", timeout);
|
||||
}
|
||||
|
||||
private String yesNo(Boolean value) {
|
||||
return Boolean.TRUE.equals(value) ? "Y" : "N";
|
||||
}
|
||||
|
||||
private Sheet requiredSheet(Workbook workbook, String name) {
|
||||
Sheet sheet = workbook.getSheet(name);
|
||||
if (sheet == null) {
|
||||
throw new IllegalStateException("Excel 템플릿에 '" + name + "' 시트가 없습니다.");
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
private void resetSheet(Sheet sheet, int rows, int columns) {
|
||||
for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) {
|
||||
sheet.removeMergedRegion(index);
|
||||
}
|
||||
for (int rowIndex = 0; rowIndex < rows; rowIndex++) {
|
||||
Row row = row(sheet, rowIndex);
|
||||
for (int column = 0; column < columns; column++) {
|
||||
cell(row, column).setBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeSet(Sheet sheet, String range, String value, CellStyle style) {
|
||||
CellRangeAddress address = CellRangeAddress.valueOf(range);
|
||||
sheet.addMergedRegion(address);
|
||||
for (int rowIndex = address.getFirstRow(); rowIndex <= address.getLastRow(); rowIndex++) {
|
||||
for (int column = address.getFirstColumn(); column <= address.getLastColumn(); column++) {
|
||||
Cell target = cell(row(sheet, rowIndex), column);
|
||||
target.setCellStyle(style);
|
||||
if (rowIndex == address.getFirstRow() && column == address.getFirstColumn()) {
|
||||
target.setCellValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void set(Sheet sheet, String reference, String value) {
|
||||
CellReference cellReference = new CellReference(reference);
|
||||
Cell target = cell(row(sheet, cellReference.getRow()), cellReference.getCol());
|
||||
target.setBlank();
|
||||
target.setCellValue(defaultValue(value, ""));
|
||||
}
|
||||
|
||||
private void setStyled(Sheet sheet, int rowIndex, int column, String value, CellStyle style) {
|
||||
Cell target = cell(row(sheet, rowIndex), column);
|
||||
target.setBlank();
|
||||
target.setCellStyle(style);
|
||||
target.setCellValue(defaultValue(value, ""));
|
||||
}
|
||||
|
||||
private CellStyle style(Sheet sheet, String reference) {
|
||||
CellReference cellReference = new CellReference(reference);
|
||||
return cell(row(sheet, cellReference.getRow()), cellReference.getCol()).getCellStyle();
|
||||
}
|
||||
|
||||
private Row row(Sheet sheet, int rowIndex) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
return row == null ? sheet.createRow(rowIndex) : row;
|
||||
}
|
||||
|
||||
private Cell cell(Row row, int column) {
|
||||
Cell cell = row.getCell(column);
|
||||
return cell == null ? row.createCell(column) : cell;
|
||||
}
|
||||
|
||||
private String value(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String defaultValue(String value, String fallback) {
|
||||
return isBlank(value) ? (fallback == null ? "" : fallback) : value;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record SchemaField(String path, String type, String description, String length,
|
||||
boolean required, String example, boolean coded) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
public record GeneratedDocument(String fileName, byte[] content) {
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.guardrail;
|
||||
package io.shinhanlife.dap.mcg.guardrail;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.FailureType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.ToolExecutionException;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.guardrail
|
||||
* @className GuardrailService
|
||||
* @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 io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -1,8 +1,23 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.guardrail;
|
||||
package io.shinhanlife.dap.mcg.guardrail;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.FailureType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.ToolExecutionException;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.guardrail
|
||||
* @className ToolResponseGuardrailService
|
||||
* @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 io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.messaging;
|
||||
package io.shinhanlife.dap.mcg.messaging;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -10,16 +10,16 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.messaging
|
||||
* @package io.shinhanlife.dap.mcg.messaging
|
||||
* @className KafkaProducerService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -1,8 +1,24 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.presentation;
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.service.ExecuteService;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.presentation
|
||||
* @className ChatController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcg.service.ExecuteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
@@ -11,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;
|
||||
|
||||
@@ -26,6 +43,7 @@ public class ChatController {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisRegistryService registryService;
|
||||
private final ChatClient.Builder chatClientBuilder;
|
||||
private final McpRouterController mcpRouterController;
|
||||
|
||||
@PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter chatStream(@RequestBody Map<String, String> request,
|
||||
@@ -39,19 +57,45 @@ public class ChatController {
|
||||
|
||||
try {
|
||||
List<ToolCallback> callbacks = new ArrayList<>();
|
||||
for (ToolMetadata meta : registryService.getAllTools()) {
|
||||
if (Boolean.TRUE.equals(meta.getVisible())) {
|
||||
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()) && 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", "cohere/north-mini-code:free").trim();
|
||||
if (selectedModel.isEmpty()) {
|
||||
selectedModel = "cohere/north-mini-code:free";
|
||||
}
|
||||
|
||||
// 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()
|
||||
.toolCallbacks(callbacks.toArray(new ToolCallback[0]))
|
||||
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
|
||||
.model(selectedModel).build())
|
||||
.stream()
|
||||
.content();
|
||||
|
||||
@@ -74,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 + "]");
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
|
||||
/**
|
||||
* Gateway 화면에서 들어온 DTO 엑셀 요청을 실제 DTO 클래스가 있는 Tool Pod로 전달한다.
|
||||
* 브라우저가 동일 출처(8081)만 호출하도록 하여 CORS와 배포 주소 차이를 숨긴다.
|
||||
*/
|
||||
@RestController
|
||||
public class DtoDownloadProxyController {
|
||||
|
||||
private static final MediaType XLSX_MEDIA_TYPE = MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
|
||||
private final RestClient restClient;
|
||||
private final String toolPodUrl;
|
||||
|
||||
public DtoDownloadProxyController(
|
||||
RestClient.Builder restClientBuilder,
|
||||
@Value("${mcp.gateway.fallback.default-url:http://localhost:8084}") String toolPodUrl) {
|
||||
this.restClient = restClientBuilder.build();
|
||||
this.toolPodUrl = toolPodUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
@GetMapping("/dto-download/options")
|
||||
public List<String> options() {
|
||||
// 셀렉트박스에 표시할 DTO 이름 목록을 Tool Pod에서 조회한다.
|
||||
List<String> options = restClient.get()
|
||||
.uri(toolPodUrl + "/dto-download/options")
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {});
|
||||
return options == null ? List.of() : options;
|
||||
}
|
||||
|
||||
@GetMapping("/dto-download/{dtoName}")
|
||||
public ResponseEntity<byte[]> download(@PathVariable String dtoName) {
|
||||
byte[] workbook;
|
||||
try {
|
||||
// 생성된 엑셀 바이트를 그대로 브라우저에 전달한다.
|
||||
workbook = restClient.get()
|
||||
.uri(toolPodUrl + "/dto-download/{dtoName}", dtoName)
|
||||
.retrieve()
|
||||
.body(byte[].class);
|
||||
} catch (RestClientResponseException error) {
|
||||
// 형식 불일치 등의 상태 코드와 오류 메시지도 변경 없이 전달한다.
|
||||
return ResponseEntity.status(error.getStatusCode())
|
||||
.contentType(error.getResponseHeaders() != null
|
||||
&& error.getResponseHeaders().getContentType() != null
|
||||
? error.getResponseHeaders().getContentType() : MediaType.TEXT_PLAIN)
|
||||
.body(error.getResponseBodyAsByteArray());
|
||||
}
|
||||
String fileName = dtoName + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(XLSX_MEDIA_TYPE)
|
||||
.contentLength(workbook == null ? 0 : workbook.length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(fileName).build().toString())
|
||||
.body(workbook == null ? new byte[0] : workbook);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.presentation;
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.service.KillSwitchService;
|
||||
import io.shinhanlife.dap.mcg.service.KillSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -8,16 +8,16 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.presentation
|
||||
* @package io.shinhanlife.dap.mcg.presentation
|
||||
* @className KillSwitchController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -1,14 +1,29 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.presentation;
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.presentation
|
||||
* @className McpRouterController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.JsonRpcRequest;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.JsonRpcResponse;
|
||||
import io.shinhanlife.dap.biz.mcp.adapter.dto.Params;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.GatewayFallbackProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.service.ExecuteService;
|
||||
import io.shinhanlife.dap.common.mcp.security.SecurityProperties;
|
||||
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.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcg.service.ExecuteService;
|
||||
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -76,7 +91,7 @@ public class McpRouterController {
|
||||
try {
|
||||
Object result = executeService.execute(payload, effectiveTenantId);
|
||||
|
||||
io.shinhanlife.dap.biz.mcp.adapter.dto.JsonRpcResponse response = new io.shinhanlife.dap.biz.mcp.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);
|
||||
@@ -97,13 +112,16 @@ public class McpRouterController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/tools/list")
|
||||
public ResponseEntity<JsonRpcResponse> listTools(
|
||||
@RequestParam(value = "categoryKey", required = false) String categoryKey) {
|
||||
List<ToolMetadata> activeTools = redisRegistryService.getAllTools()
|
||||
private List<ToolMetadata> fetchAllActiveTools() {
|
||||
List<ToolMetadata> activeTools = new ArrayList<>();
|
||||
try {
|
||||
activeTools.addAll(redisRegistryService.getAllTools()
|
||||
.stream()
|
||||
.filter(ToolMetadata::getVisible)
|
||||
.collect(Collectors.toList());
|
||||
.collect(Collectors.toList()));
|
||||
} catch (org.springframework.data.redis.RedisConnectionFailureException exception) {
|
||||
log.warn("Redis is unavailable. Fetching tools from configured fallback Tool Pods instead.");
|
||||
}
|
||||
|
||||
Set<String> knownTools = activeTools.stream()
|
||||
.map(ToolMetadata::getUid)
|
||||
@@ -133,6 +151,14 @@ public class McpRouterController {
|
||||
log.warn("Failed to fetch local tools from fallback URL: {}", url);
|
||||
}
|
||||
}
|
||||
return activeTools;
|
||||
}
|
||||
|
||||
@GetMapping("/tools/list")
|
||||
public ResponseEntity<JsonRpcResponse> listTools(
|
||||
@RequestParam(value = "categoryKey", required = false) String categoryKey) {
|
||||
|
||||
List<ToolMetadata> activeTools = fetchAllActiveTools();
|
||||
|
||||
if (categoryKey != null && !categoryKey.trim().isEmpty()) {
|
||||
activeTools = activeTools.stream()
|
||||
@@ -146,12 +172,10 @@ public class McpRouterController {
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/tools/docs/markdown", produces = "text/markdown;charset=UTF-8")
|
||||
public ResponseEntity<String> generateToolsMarkdown() {
|
||||
List<ToolMetadata> tools = redisRegistryService.getAllTools()
|
||||
.stream()
|
||||
.filter(ToolMetadata::getVisible)
|
||||
.collect(Collectors.toList());
|
||||
List<ToolMetadata> tools = fetchAllActiveTools();
|
||||
|
||||
StringBuilder md = new StringBuilder();
|
||||
md.append("# \uD83E\uDD16 Shinhan AI Tool Catalog\n\n");
|
||||
@@ -0,0 +1,417 @@
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.presentation
|
||||
* @className ScaffoldingController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.lib.util.PodScaffolder;
|
||||
import io.shinhanlife.dap.lib.util.ToolScaffolder;
|
||||
import io.shinhanlife.dap.lib.util.ToolSourceUpdater;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.File;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/scaffold")
|
||||
public class ScaffoldingController {
|
||||
|
||||
private static final Set<String> SUPPORTED_FIELD_TYPES = Set.of(
|
||||
"String", "Integer", "Long", "Double", "Boolean", "BigDecimal", "Enum", "List");
|
||||
private static final Set<String> SUPPORTED_AI_MODELS = Set.of(
|
||||
"inclusionai/ling-3.0-flash:free",
|
||||
"openai/gpt-oss-20b:free",
|
||||
"google/gemma-4-31b-it:free",
|
||||
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||
"cohere/north-mini-code:free");
|
||||
|
||||
private final ChatClient.Builder chatClientBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ScaffoldingController(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
|
||||
this.chatClientBuilder = chatClientBuilder;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/pod")
|
||||
public String scaffoldPod(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String moduleName = req.getOrDefault("moduleName", "dap-was-oth");
|
||||
if (!moduleName.startsWith("dap-was-")) moduleName = "dap-was-" + moduleName;
|
||||
String port = req.getOrDefault("port", "8085");
|
||||
String shortName = moduleName.replace("dap-was-", "").replace("-", "");
|
||||
String author = req.get("author");
|
||||
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
|
||||
String date = req.get("date");
|
||||
if (date == null || date.trim().isEmpty()) date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
|
||||
|
||||
return PodScaffolder.scaffoldPod(moduleName, port, shortName, author, date);
|
||||
} catch (Exception e) {
|
||||
return "오류 발생: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool")
|
||||
public String scaffoldTool(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String baseName = req.get("baseName");
|
||||
String interfaceId = req.get("interfaceId");
|
||||
String title = req.get("title");
|
||||
if (title == null || title.isBlank()) title = baseName;
|
||||
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-was-oth");
|
||||
String author = req.get("author");
|
||||
if (author == null || author.trim().isEmpty()) author = System.getProperty("user.name");
|
||||
String date = req.get("date");
|
||||
if (date == null || date.trim().isEmpty()) date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
|
||||
boolean register = Boolean.parseBoolean(req.getOrDefault("register", "true"));
|
||||
String clientSystemCode = req.get("clientSystemCode");
|
||||
String httpApiName = req.get("httpApiName");
|
||||
String inputSchemaResource = req.get("inputSchemaResource");
|
||||
String outputSchemaResource = req.get("outputSchemaResource");
|
||||
List<ToolScaffolder.FieldDefinition> inputFields = parseFields(req.get("inputFields"));
|
||||
List<ToolScaffolder.FieldDefinition> outputFields = parseFields(req.get("outputFields"));
|
||||
if (inputFields.isEmpty()) {
|
||||
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false));
|
||||
}
|
||||
ToolScaffolder.ToolDefinitionOptions definitionOptions = new ToolScaffolder.ToolDefinitionOptions(
|
||||
req.get("functionDescription"),
|
||||
req.get("whenToUse"),
|
||||
req.get("whenNotToUse"),
|
||||
req.get("ioLimits"),
|
||||
req.get("displayDescription"),
|
||||
parseDelimited(req.get("exampleQueries")),
|
||||
parseDelimited(req.get("tags")),
|
||||
req.get("ownerOrg"));
|
||||
|
||||
return ToolScaffolder.scaffold(baseName, interfaceId, title, description, group, routingType,
|
||||
moduleName, author, date, register, clientSystemCode, inputSchemaResource,
|
||||
outputSchemaResource, inputFields, outputFields, httpApiName, definitionOptions);
|
||||
} catch (Exception e) {
|
||||
return "오류 발생: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool-group")
|
||||
public String scaffoldToolGroup(@RequestBody ToolGroupRequest request) {
|
||||
try {
|
||||
if (request == null || request.useCaseName() == null
|
||||
|| !request.useCaseName().trim().matches("^[A-Z][A-Za-z0-9]*$")) {
|
||||
throw new IllegalArgumentException("UseCase name must be PascalCase.");
|
||||
}
|
||||
String moduleName = request.moduleName() == null || request.moduleName().isBlank()
|
||||
? "dap-was-oth" : request.moduleName().trim();
|
||||
String author = request.author() == null || request.author().isBlank()
|
||||
? System.getProperty("user.name") : request.author().trim();
|
||||
String date = request.date() == null || request.date().isBlank()
|
||||
? LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) : request.date().trim();
|
||||
return ToolScaffolder.scaffoldUseCase(request.useCaseName().trim(), moduleName, author, date,
|
||||
request.tools() == null ? List.of() : request.tools());
|
||||
} catch (Exception e) {
|
||||
return "Error: " + safeMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/usecases")
|
||||
public List<String> listUseCases(@RequestParam String moduleName, @RequestParam String categoryKey) {
|
||||
if (moduleName == null || !moduleName.matches("^dap-was-[a-z0-9-]+$")) {
|
||||
throw new IllegalArgumentException("Invalid target module.");
|
||||
}
|
||||
if (categoryKey == null || !categoryKey.matches("^[a-z0-9]{3}$")) {
|
||||
throw new IllegalArgumentException("Invalid domain category.");
|
||||
}
|
||||
String sourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
if (sourceDir == null || sourceDir.isBlank()) sourceDir = System.getProperty("user.dir");
|
||||
File useCaseDir = new File(sourceDir, moduleName + "/src/main/java/io/shinhanlife/dap/mcc/biz/"
|
||||
+ categoryKey + "/usecase");
|
||||
File[] files = useCaseDir.listFiles(file -> file.isFile() && file.getName().endsWith("UseCase.java"));
|
||||
if (files == null) return List.of();
|
||||
return Arrays.stream(files).map(File::getName)
|
||||
.map(name -> name.substring(0, name.length() - ".java".length()))
|
||||
.sorted().toList();
|
||||
}
|
||||
|
||||
@PostMapping("/field-draft")
|
||||
public ResponseEntity<?> generateFieldDraft(@RequestBody Map<String, String> req) {
|
||||
String description = req.getOrDefault("description", "").trim();
|
||||
String target = req.getOrDefault("target", "inputFields");
|
||||
if (description.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "설명을 입력해주세요."));
|
||||
}
|
||||
if (!"inputFields".equals(target) && !"outputFields".equals(target)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "지원하지 않는 필드 대상입니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
String targetLabel = "inputFields".equals(target) ? "INPUT request DTO" : "OUTPUT response DTO";
|
||||
String prompt = """
|
||||
Generate Java DTO fields for an MCP tool.
|
||||
Return JSON only. Do not add Markdown, explanations, or code fences.
|
||||
The response must have this exact shape:
|
||||
{"fields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List.
|
||||
Enum fields must include enumValues. List fields must include itemType; use Object plus itemFields for object lists.
|
||||
Generate fields only for the requested target: %s.
|
||||
For OUTPUT fields, include resultCode and resultMessage when appropriate.
|
||||
Keep field names valid Java camelCase identifiers. Generate at most 10 fields.
|
||||
User description: %s
|
||||
""".formatted(targetLabel, description);
|
||||
|
||||
String response = generateAiContent(prompt, req.get("model"));
|
||||
FieldDraft draft = objectMapper.readValue(stripCodeFence(response), FieldDraft.class);
|
||||
List<ToolScaffolder.FieldDefinition> fields = validateFields(draft.fields());
|
||||
return ResponseEntity.ok(Map.of("fields", fields));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "AI 초안 생성 실패: " + safeMessage(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool-draft")
|
||||
public ResponseEntity<?> generateToolDraft(@RequestBody Map<String, String> req) {
|
||||
String description = req.getOrDefault("description", "").trim();
|
||||
if (description.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Tool 설명을 입력해주세요."));
|
||||
}
|
||||
|
||||
try {
|
||||
String prompt = """
|
||||
Generate an MCP Tool scaffold from the user request.
|
||||
Return JSON only. Do not add Markdown, explanations, or code fences.
|
||||
The response must have this exact shape:
|
||||
{"baseName":"PascalCaseName","title":"short Korean title","description":"clear Korean LLM tool guidance","categoryKey":"cmm","routingType":"HTTP","httpApiName":"simple-api-name","functionDescription":"core business function","displayDescription":"short portal description","whenToUse":"specific user requests that should select this tool","whenNotToUse":"requests or conditions that must not select this tool","ioLimits":"allowed input and output scope and limits","exampleQueries":["query 1","query 2","query 3"],"tags":["domain","action"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true,"enumValues":[],"itemType":null,"itemFields":[]}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true,"enumValues":[],"itemType":null,"itemFields":[]}]}
|
||||
categoryKey must be exactly three lowercase letters or digits.
|
||||
routingType must be either HTTP or MCI. httpApiName can contain only letters, digits, hyphens, and underscores.
|
||||
Write every V17 metadata field for its distinct purpose; do not copy the same sentence into all fields.
|
||||
Generate 3 to 10 realistic exampleQueries and concise search tags. Use MCP_TOOL for ownerOrg unless the user names an owner.
|
||||
Allowed field type values: String, Integer, Long, Double, Boolean, BigDecimal, Enum, List. Enum must include enumValues; List must include itemType and object lists include itemFields.
|
||||
Keep all field names valid Java camelCase identifiers. Generate at most 10 fields per list.
|
||||
Do not generate interfaceId or clientSystemCode; those must come from a real integration contract.
|
||||
User request: %s
|
||||
""".formatted(description);
|
||||
|
||||
String response = generateAiContent(prompt, req.get("model"));
|
||||
ToolDraft draft = objectMapper.readValue(stripCodeFence(response), ToolDraft.class);
|
||||
ToolDraft validatedDraft = validateToolDraft(draft);
|
||||
return ResponseEntity.ok(validatedDraft);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "AI Tool 초안 생성 실패: " + safeMessage(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/tool/update")
|
||||
public String updateTool(@RequestBody Map<String, String> req) {
|
||||
try {
|
||||
String toolName = req.get("toolName");
|
||||
String domainGroup = req.getOrDefault("categoryKey", req.get("domainGroup"));
|
||||
String description = req.get("description");
|
||||
boolean register = Boolean.parseBoolean(req.getOrDefault("register", "true"));
|
||||
Boolean requiresApproval = req.containsKey("requiresApproval") ? Boolean.parseBoolean(req.get("requiresApproval")) : null;
|
||||
|
||||
ToolSourceUpdater.updateToolSource(toolName, domainGroup, description, register, requiresApproval);
|
||||
return "성공";
|
||||
} catch (Exception e) {
|
||||
return "오류 발생: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/modules")
|
||||
public List<String> listModules() {
|
||||
try {
|
||||
String sourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
if (sourceDir == null) sourceDir = System.getProperty("user.dir");
|
||||
|
||||
File dir = new File(sourceDir);
|
||||
File[] files = dir.listFiles(f -> f.isDirectory() && f.getName().startsWith("dap-was-") && !f.getName().equals("dap-was-lib"));
|
||||
|
||||
if (files == null || files.length == 0) {
|
||||
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
|
||||
}
|
||||
|
||||
return Arrays.stream(files).map(File::getName).sorted().collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
return List.of("dap-was-oth", "dap-was-hr", "dap-was-sms");
|
||||
}
|
||||
}
|
||||
|
||||
private List<ToolScaffolder.FieldDefinition> parseFields(String source) throws Exception {
|
||||
if (source == null || source.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return objectMapper.readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
|
||||
}
|
||||
|
||||
private List<String> parseDelimited(String source) {
|
||||
if (source == null || source.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(source.split("[\\r\\n,]+"))
|
||||
.map(String::trim)
|
||||
.filter(value -> !value.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<ToolScaffolder.FieldDefinition> validateFields(List<ToolScaffolder.FieldDefinition> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다.");
|
||||
}
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
return source.stream()
|
||||
.limit(10)
|
||||
.filter(field -> field != null && field.name() != null && !field.name().isBlank())
|
||||
.map(field -> new ToolScaffolder.FieldDefinition(
|
||||
field.name().trim(),
|
||||
field.type() == null ? "String" : field.type().trim(),
|
||||
field.description() == null ? "" : field.description().trim(),
|
||||
field.example() == null ? "" : field.example().trim(),
|
||||
field.required(),
|
||||
field.enumValues() == null ? List.of() : field.enumValues(),
|
||||
field.itemType(),
|
||||
field.itemFields() == null ? List.of() : field.itemFields()))
|
||||
.peek(field -> {
|
||||
if (!field.name().matches("^[A-Za-z_$][A-Za-z0-9_$]*$")) {
|
||||
throw new IllegalArgumentException("AI가 올바르지 않은 필드명을 생성했습니다: " + field.name());
|
||||
}
|
||||
if (!SUPPORTED_FIELD_TYPES.contains(field.type())) {
|
||||
throw new IllegalArgumentException("AI가 지원하지 않는 Type을 생성했습니다: " + field.type());
|
||||
}
|
||||
validateStructuredField(field);
|
||||
if (!names.add(field.name())) {
|
||||
throw new IllegalArgumentException("AI가 중복 필드명을 생성했습니다: " + field.name());
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void validateStructuredField(ToolScaffolder.FieldDefinition field) {
|
||||
if ("Enum".equals(field.type()) && field.enumValues().isEmpty()) {
|
||||
throw new IllegalArgumentException("Enum field needs enumValues: " + field.name());
|
||||
}
|
||||
if ("List".equals(field.type()) && (field.itemType() == null || field.itemType().isBlank())) {
|
||||
throw new IllegalArgumentException("List field needs itemType: " + field.name());
|
||||
}
|
||||
if ("List".equals(field.type()) && "Object".equals(field.itemType()) && field.itemFields().isEmpty()) {
|
||||
throw new IllegalArgumentException("Object List field needs itemFields: " + field.name());
|
||||
}
|
||||
}
|
||||
|
||||
private ToolDraft validateToolDraft(ToolDraft draft) {
|
||||
if (draft == null || draft.baseName() == null || !draft.baseName().trim().matches("^[A-Z][A-Za-z0-9]*$")) {
|
||||
throw new IllegalArgumentException("AI가 올바르지 않은 Base Name을 생성했습니다.");
|
||||
}
|
||||
String categoryKey = draft.categoryKey() == null ? "" : draft.categoryKey().trim().toLowerCase(Locale.ROOT);
|
||||
if (!categoryKey.matches("^[a-z0-9]{3}$")) {
|
||||
throw new IllegalArgumentException("AI가 올바르지 않은 Category Key를 생성했습니다.");
|
||||
}
|
||||
String routingType = draft.routingType() == null ? "" : draft.routingType().trim().toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("HTTP", "MCI").contains(routingType)) {
|
||||
throw new IllegalArgumentException("AI가 지원하지 않는 Protocol을 생성했습니다.");
|
||||
}
|
||||
String httpApiName = draft.httpApiName() == null ? "http-api" : draft.httpApiName().trim();
|
||||
if (!httpApiName.matches("^[A-Za-z0-9_-]+$")) {
|
||||
throw new IllegalArgumentException("AI가 올바르지 않은 HTTP API Name을 생성했습니다.");
|
||||
}
|
||||
String title = draft.title() == null ? "" : draft.title().trim();
|
||||
String description = draft.description() == null ? "" : draft.description().trim();
|
||||
if (title.isBlank() || description.isBlank()) {
|
||||
throw new IllegalArgumentException("AI가 Tool 제목 또는 설명을 생성하지 않았습니다.");
|
||||
}
|
||||
String functionDescription = textOrDefault(draft.functionDescription(), description);
|
||||
String displayDescription = textOrDefault(draft.displayDescription(), title);
|
||||
String whenToUse = textOrDefault(draft.whenToUse(), description + " 요청을 처리할 때 사용한다.");
|
||||
String whenNotToUse = textOrDefault(draft.whenNotToUse(), "필수 입력값이 없거나 다른 업무 요청에는 사용하지 않는다.");
|
||||
String ioLimits = textOrDefault(draft.ioLimits(), "정의된 입력 필드만 허용하며 정의된 응답 DTO 범위만 반환한다.");
|
||||
List<String> exampleQueries = normalizedDraftList(draft.exampleQueries(), List.of(
|
||||
title + " 해줘", title + " 정보를 알려줘", title + " 결과를 확인해줘"));
|
||||
List<String> tags = normalizedDraftList(draft.tags(), List.of(categoryKey));
|
||||
String ownerOrg = textOrDefault(draft.ownerOrg(), "MCP_TOOL");
|
||||
return new ToolDraft(draft.baseName().trim(), title, description, categoryKey, routingType, httpApiName,
|
||||
functionDescription, displayDescription, whenToUse, whenNotToUse, ioLimits,
|
||||
exampleQueries, tags, ownerOrg,
|
||||
validateFields(draft.inputFields()), validateFields(draft.outputFields()));
|
||||
}
|
||||
|
||||
private String textOrDefault(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value.trim();
|
||||
}
|
||||
|
||||
private List<String> normalizedDraftList(List<String> values, List<String> fallback) {
|
||||
if (values == null) {
|
||||
return fallback;
|
||||
}
|
||||
List<String> normalized = values.stream()
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.toList();
|
||||
return normalized.isEmpty() ? fallback : normalized;
|
||||
}
|
||||
|
||||
private String generateAiContent(String prompt, String requestedModel) {
|
||||
return chatClientBuilder.build().prompt()
|
||||
.user(prompt)
|
||||
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
|
||||
.model(resolveModel(requestedModel)).build())
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
private String resolveModel(String requestedModel) {
|
||||
String model = requestedModel == null || requestedModel.isBlank()
|
||||
? "cohere/north-mini-code:free" : requestedModel.trim();
|
||||
if (!SUPPORTED_AI_MODELS.contains(model)) {
|
||||
throw new IllegalArgumentException("지원하지 않는 AI 모델입니다.");
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
private String stripCodeFence(String response) {
|
||||
if (response == null) {
|
||||
throw new IllegalArgumentException("AI 응답이 비어 있습니다.");
|
||||
}
|
||||
return response.trim().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```$", "").trim();
|
||||
}
|
||||
|
||||
private String safeMessage(Exception e) {
|
||||
return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
}
|
||||
|
||||
private record FieldDraft(List<ToolScaffolder.FieldDefinition> fields) {
|
||||
}
|
||||
|
||||
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
|
||||
String httpApiName, String functionDescription, String displayDescription,
|
||||
String whenToUse, String whenNotToUse, String ioLimits,
|
||||
List<String> exampleQueries, List<String> tags, String ownerOrg,
|
||||
List<ToolScaffolder.FieldDefinition> inputFields,
|
||||
List<ToolScaffolder.FieldDefinition> outputFields) {
|
||||
}
|
||||
|
||||
private record ToolGroupRequest(String useCaseName, String moduleName, String author, String date,
|
||||
List<ToolScaffolder.ToolMethodDefinition> tools) {
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.redis;
|
||||
package io.shinhanlife.dap.mcg.redis;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.redis
|
||||
* @className McpMonitorEventService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.redis;
|
||||
package io.shinhanlife.dap.mcg.redis;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.guardrail.SensitiveDataMasker;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.security.McpRequestContext;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.redis
|
||||
* @className RedisToolTraceService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.mcg.security.McpRequestContext;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
@@ -34,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,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);
|
||||
@@ -1,6 +1,6 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.registry;
|
||||
package io.shinhanlife.dap.mcg.registry;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
@@ -13,16 +13,16 @@ import java.util.stream.Collectors;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.registry
|
||||
* @package io.shinhanlife.dap.mcg.registry
|
||||
* @className RedisRegistryService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className CircuitBreaker
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className CircuitBreakerMonitorController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -1,6 +1,21 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.McpGatewayProperties;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className CircuitBreakerService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Clock;
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className CircuitBreakerSnapshot
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
/**
|
||||
* MCP Monitor 화면에 내려주는 Circuit Breaker 상태 정보입니다.
|
||||
*/
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className CircuitBreakerState
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
/**
|
||||
* Circuit Breaker의 현재 상태입니다.
|
||||
*
|
||||
* CLOSED는 정상 호출 가능, OPEN은 호출 차단, HALF_OPEN은 복구 확인 상태를 의미합니다.
|
||||
*/
|
||||
public enum CircuitBreakerState {
|
||||
CLOSED,
|
||||
OPEN,
|
||||
HALF_OPEN
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className FailureType
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
/**
|
||||
* Tool/EIMS/Tool 서버 호출 실패를 업무적으로 구분하기 위한 장애 유형입니다.
|
||||
*
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className RetryPolicy
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
/**
|
||||
* Tool 호출 재시도 정책입니다.
|
||||
*
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.resilience;
|
||||
package io.shinhanlife.dap.mcg.resilience;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.resilience
|
||||
* @className ToolExecutionException
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
/**
|
||||
* Tool 실행 중 발생한 오류를 FailureType과 함께 전달하는 공통 예외입니다.
|
||||
*
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.security;
|
||||
package io.shinhanlife.dap.mcg.security;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.security
|
||||
* @className McpRequestContext
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,20 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.security;
|
||||
package io.shinhanlife.dap.mcg.security;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.security
|
||||
* @className McpRequestContextResolver
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
@@ -1,10 +1,25 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.security;
|
||||
package io.shinhanlife.dap.mcg.security;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.FailureType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.ToolExecutionException;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.OperationType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.security
|
||||
* @className ToolAuthorizationService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
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.lib.dto.OperationType;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -1,20 +1,36 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.service;
|
||||
package io.shinhanlife.dap.mcg.service;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.FailureType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.RetryPolicy;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.ToolExecutionException;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.OperationType;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.guardrail.SensitiveDataMasker;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.guardrail.GuardrailService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.security.McpRequestContext;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.security.McpRequestContextResolver;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.audit.AuditLogService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.CircuitBreaker;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.resilience.CircuitBreakerService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.security.ToolAuthorizationService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.redis.RedisToolTraceService;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.service
|
||||
* @className ExecuteService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import java.util.HashMap;
|
||||
|
||||
import io.shinhanlife.dap.mcc.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.lib.dto.OperationType;
|
||||
import io.shinhanlife.dap.mcg.config.McpGatewayProperties;
|
||||
import io.shinhanlife.dap.mcg.guardrail.GuardrailService;
|
||||
import io.shinhanlife.dap.mcg.security.McpRequestContext;
|
||||
import io.shinhanlife.dap.mcg.security.McpRequestContextResolver;
|
||||
import io.shinhanlife.dap.mcg.audit.AuditLogService;
|
||||
import io.shinhanlife.dap.mcg.resilience.CircuitBreaker;
|
||||
import io.shinhanlife.dap.mcg.resilience.CircuitBreakerService;
|
||||
import io.shinhanlife.dap.mcg.security.ToolAuthorizationService;
|
||||
import io.shinhanlife.dap.mcg.redis.RedisToolTraceService;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import java.util.Map;
|
||||
@@ -27,12 +43,12 @@ import org.springframework.web.client.RestClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.tool.large.LargeToolResponseService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.tool.large.PaginationRequestValidator;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.tool.result.ToolExecutionResultFormatter;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.tool.result.ToolExecutionResult;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.guardrail.ToolResponseGuardrailService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.transport.ToolInvoker;
|
||||
import io.shinhanlife.dap.mcg.tool.large.LargeToolResponseService;
|
||||
import io.shinhanlife.dap.mcg.tool.large.PaginationRequestValidator;
|
||||
import io.shinhanlife.dap.mcg.tool.result.ToolExecutionResultFormatter;
|
||||
import io.shinhanlife.dap.mcg.tool.result.ToolExecutionResult;
|
||||
import io.shinhanlife.dap.mcg.guardrail.ToolResponseGuardrailService;
|
||||
import io.shinhanlife.dap.mcg.transport.ToolInvoker;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -41,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;
|
||||
@@ -59,7 +74,6 @@ public class ExecuteService {
|
||||
public ExecuteService(ToolPlanner planner,
|
||||
KillSwitchService killSwitchService,
|
||||
ObjectMapper objectMapper,
|
||||
SensitiveDataMasker dataMasker,
|
||||
GuardrailService guardrailService,
|
||||
McpRequestContextResolver contextResolver,
|
||||
AuditLogService auditLogService,
|
||||
@@ -75,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;
|
||||
@@ -145,22 +158,53 @@ public class ExecuteService {
|
||||
long elapsedMillis = elapsedMillis(startedAt);
|
||||
|
||||
String responseText = "";
|
||||
try { responseText = objectMapper.writeValueAsString(result); } catch (Exception ignore) {}
|
||||
long originalSize = 0;
|
||||
try {
|
||||
responseText = objectMapper.writeValueAsString(result);
|
||||
originalSize = responseText.getBytes(java.nio.charset.StandardCharsets.UTF_8).length;
|
||||
} catch (Exception ignore) {}
|
||||
|
||||
Map<String, Object> finalResult = new java.util.LinkedHashMap<>();
|
||||
finalResult.put("status", "ok");
|
||||
finalResult.put("result", result);
|
||||
finalResult.put("error_code", null);
|
||||
finalResult.put("error_message", null);
|
||||
finalResult.put("elapsed_ms", elapsedMillis);
|
||||
finalResult.put("truncated", false);
|
||||
finalResult.put("original_size", originalSize);
|
||||
|
||||
auditLogService.toolFinished(context, metadata.getName(), elapsedMillis, true, "");
|
||||
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, true, "", responseText);
|
||||
|
||||
return result;
|
||||
return finalResult;
|
||||
} catch (ToolExecutionException error) {
|
||||
long elapsedMillis = elapsedMillis(startedAt);
|
||||
auditLogService.toolFinished(context, toolName, elapsedMillis, false, error.failureType().name());
|
||||
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, error.failureType().name(), "");
|
||||
throw error;
|
||||
|
||||
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
|
||||
errorResult.put("status", "error");
|
||||
errorResult.put("result", null);
|
||||
errorResult.put("error_code", error.failureType().name());
|
||||
errorResult.put("error_message", error.getMessage());
|
||||
errorResult.put("elapsed_ms", elapsedMillis);
|
||||
errorResult.put("truncated", false);
|
||||
errorResult.put("original_size", 0);
|
||||
return errorResult;
|
||||
} catch (Exception error) {
|
||||
long elapsedMillis = elapsedMillis(startedAt);
|
||||
auditLogService.toolFinished(context, toolName, elapsedMillis, false, FailureType.INTERNAL_ERROR.name());
|
||||
redisTrace.finished(context, metadata, argumentsNode, elapsedMillis, false, FailureType.INTERNAL_ERROR.name(), "");
|
||||
throw new ToolExecutionException(FailureType.INTERNAL_ERROR, "Tool execution failed: " + toolName, error);
|
||||
|
||||
Map<String, Object> errorResult = new java.util.LinkedHashMap<>();
|
||||
errorResult.put("status", "error");
|
||||
errorResult.put("result", null);
|
||||
errorResult.put("error_code", FailureType.INTERNAL_ERROR.name());
|
||||
errorResult.put("error_message", error.getMessage());
|
||||
errorResult.put("elapsed_ms", elapsedMillis);
|
||||
errorResult.put("truncated", false);
|
||||
errorResult.put("original_size", 0);
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,34 +257,33 @@ public class ExecuteService {
|
||||
if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) {
|
||||
targetUrl = metadata.getPodUrl();
|
||||
}
|
||||
String executeApiUrl = targetUrl + "/mcp/api/v1/tools/call";
|
||||
|
||||
ObjectNode pageArguments = paginationValidator.normalize(arguments);
|
||||
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(metadata, arguments);
|
||||
LargeToolResponseService.Collector collector = largeResponses.newCollector(metadata.getName(), context.requestId());
|
||||
|
||||
while (true) {
|
||||
Map<String, Object> pagePayload = new java.util.HashMap<>(payload);
|
||||
if (pagePayload.containsKey("params")) {
|
||||
Map<String, Object> params = new java.util.HashMap<>((Map<String, Object>) pagePayload.get("params"));
|
||||
params.put("arguments", objectMapper.convertValue(pageArguments, Map.class));
|
||||
pagePayload.put("params", params);
|
||||
} else {
|
||||
pagePayload.put("arguments", objectMapper.convertValue(pageArguments, Map.class));
|
||||
}
|
||||
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);
|
||||
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);
|
||||
data = toolInvoker.invoke(metadata.getName(), pagePayload, fallbackUrl, headers);
|
||||
} catch (Exception ex) {
|
||||
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.service;
|
||||
package io.shinhanlife.dap.mcg.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -6,16 +6,16 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.service
|
||||
* @package io.shinhanlife.dap.mcg.service
|
||||
* @className KillSwitchService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -1,9 +1,9 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.service;
|
||||
package io.shinhanlife.dap.mcg.service;
|
||||
|
||||
import io.shinhanlife.dap.common.mcp.security.SecurityProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.GatewayFallbackProperties;
|
||||
import io.shinhanlife.dap.lib.mcp.security.SecurityProperties;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.config.GatewayFallbackProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -12,16 +12,16 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.service
|
||||
* @package io.shinhanlife.dap.mcg.service
|
||||
* @className ToolPlanner
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -81,7 +81,7 @@ public class ToolPlanner {
|
||||
}
|
||||
|
||||
// 2-1. [신규] 도메인 그룹핑 기반 권한 검증
|
||||
if (tenantId != null && toolMetadata.getCategoryKey() != null) {
|
||||
if (tenantId != null && !tenantId.equalsIgnoreCase("system") && toolMetadata.getCategoryKey() != null) {
|
||||
String normalizedTenantId = tenantId.toLowerCase();
|
||||
List<String> allowedDomains = securityProperties.getTenantDomains().get(normalizedTenantId);
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.sync;
|
||||
package io.shinhanlife.dap.mcg.sync;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.sync
|
||||
* @className CustomWebMvcSseServerTransportProvider
|
||||
* @description AX HUB MCP Gateway SSE 전송 제공자 - SSE 기반의 MCP 서버 트랜스포트를 구현하는 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCResponse;
|
||||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.servlet.function.RouterFunction;
|
||||
import org.springframework.web.servlet.function.RouterFunctions;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -28,6 +49,7 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
private final String sseEndpoint;
|
||||
private final String messageEndpoint;
|
||||
private final Map<String, McpServerSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, CustomMcpSessionTransport> customTransports = new ConcurrentHashMap<>();
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public CustomWebMvcSseServerTransportProvider(String sseEndpoint, String messageEndpoint, ObjectMapper objectMapper) {
|
||||
@@ -50,6 +72,7 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
sessions.clear();
|
||||
customTransports.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,25 +83,25 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
.toList());
|
||||
}
|
||||
|
||||
public org.springframework.web.servlet.mvc.method.annotation.SseEmitter handleSse() {
|
||||
public SseEmitter handleSse() {
|
||||
if (sessionFactory == null) {
|
||||
throw new IllegalStateException("SessionFactory not configured");
|
||||
}
|
||||
|
||||
org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter = new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(-1L);
|
||||
SseEmitter emitter = new SseEmitter(-1L);
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
|
||||
CustomMcpSessionTransport sessionTransport = new CustomMcpSessionTransport(emitter, sessionId);
|
||||
McpServerSession session = sessionFactory.create(sessionTransport);
|
||||
sessions.put(sessionId, session);
|
||||
|
||||
emitter.onCompletion(() -> sessions.remove(sessionId));
|
||||
emitter.onTimeout(() -> sessions.remove(sessionId));
|
||||
// emitter.onCompletion(() -> sessions.remove(sessionId));
|
||||
// emitter.onTimeout(() -> sessions.remove(sessionId));
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
emitter.send(org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event().name("endpoint").data(messageEndpoint + "?sessionId=" + sessionId));
|
||||
emitter.send(SseEmitter.event().name("endpoint").data(messageEndpoint + "?sessionId=" + sessionId));
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
@@ -94,22 +117,29 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
|
||||
org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter = new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(-1L);
|
||||
|
||||
boolean isNew = !sessions.containsKey(sessionId);
|
||||
|
||||
if (isNew) {
|
||||
CustomMcpSessionTransport sessionTransport = new CustomMcpSessionTransport(emitter, sessionId);
|
||||
customTransports.put(sessionId, sessionTransport);
|
||||
McpServerSession session = sessionFactory.create(sessionTransport);
|
||||
sessions.put(sessionId, session);
|
||||
|
||||
emitter.onCompletion(() -> sessions.remove(sessionId));
|
||||
emitter.onTimeout(() -> sessions.remove(sessionId));
|
||||
// 주의: 클라이언트가 단일 POST 응답 후 연결을 끊더라도,
|
||||
// 웜 풀(Warm Pool) 스펙상 세션은 살려둬야 하므로 세션 삭제 로직 제외
|
||||
} else {
|
||||
// 기존 세션인 경우 Emitter 파이프만 덮어씌움 (Switching)
|
||||
CustomMcpSessionTransport sessionTransport = customTransports.get(sessionId);
|
||||
if (sessionTransport != null) {
|
||||
sessionTransport.setEmitter(emitter);
|
||||
}
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
// 커스텀 클라이언트는 endpoint 이벤트를 무시할 수 있지만, 표준 호환성을 위해 전송
|
||||
Thread.sleep(100);
|
||||
emitter.send(org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event().name("endpoint").data(messageEndpoint + "?sessionId=" + sessionId));
|
||||
|
||||
// Body로 들어온 initialize 등 즉시 처리
|
||||
// Body로 들어온 메시지 즉시 비동기 처리
|
||||
if (body != null && !body.trim().isEmpty()) {
|
||||
handleMessage(sessionId, body);
|
||||
handleMessage(sessionId, body, emitter);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
@@ -120,38 +150,52 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
}
|
||||
|
||||
public org.springframework.http.ResponseEntity<String> handleMessage(String sessionId, String body) {
|
||||
return handleMessage(sessionId, body, null);
|
||||
}
|
||||
|
||||
public org.springframework.http.ResponseEntity<String> handleMessage(String sessionId, String body, org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter) {
|
||||
log.info("Received POST message for sessionId: " + sessionId + ", body: " + body);
|
||||
if (sessionId == null || !sessions.containsKey(sessionId)) {
|
||||
return org.springframework.http.ResponseEntity.badRequest().body("Missing or invalid sessionId");
|
||||
return ResponseEntity.badRequest().body("Unexpected request body");
|
||||
}
|
||||
|
||||
McpServerSession session = sessions.get(sessionId);
|
||||
try {
|
||||
java.util.Map<String, Object> map = objectMapper.readValue(body, new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, Object>>() {});
|
||||
io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage message;
|
||||
Map<String, Object> map = objectMapper.readValue(body, new TypeReference<Map<String, Object>>() {});
|
||||
JSONRPCMessage message;
|
||||
|
||||
if (map.containsKey("id")) {
|
||||
if (map.containsKey("method")) {
|
||||
message = objectMapper.convertValue(map, io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest.class);
|
||||
message = objectMapper.convertValue(map, JSONRPCRequest.class);
|
||||
} else {
|
||||
message = objectMapper.convertValue(map, io.modelcontextprotocol.spec.McpSchema.JSONRPCResponse.class);
|
||||
message = objectMapper.convertValue(map, JSONRPCResponse.class);
|
||||
}
|
||||
} else {
|
||||
message = objectMapper.convertValue(map, io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification.class);
|
||||
message = objectMapper.convertValue(map, JSONRPCNotification.class);
|
||||
}
|
||||
log.info("Converted message type: " + message.getClass().getName());
|
||||
|
||||
session.handle(message).subscribe();
|
||||
log.info("Message sent to session handler");
|
||||
return org.springframework.http.ResponseEntity.ok().build();
|
||||
|
||||
if (emitter != null && !map.containsKey("id")) {
|
||||
// emitter.complete();
|
||||
log.info("Completed emitter for notification (disabled for keep-alive)");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to handle message", e);
|
||||
return org.springframework.http.ResponseEntity.status(500).body(e.getMessage());
|
||||
return ResponseEntity.status(500).body(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasSession(String sessionId) {
|
||||
return sessionId != null && sessions.containsKey(sessionId);
|
||||
}
|
||||
|
||||
private class CustomMcpSessionTransport implements McpServerTransport {
|
||||
private final org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter;
|
||||
private volatile org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter;
|
||||
private final String sessionId;
|
||||
|
||||
public CustomMcpSessionTransport(org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter, String sessionId) {
|
||||
@@ -159,6 +203,10 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public void setEmitter(org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter) {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
@@ -166,18 +214,30 @@ public class CustomWebMvcSseServerTransportProvider implements McpServerTranspor
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(message);
|
||||
log.info("Serialized message: " + json);
|
||||
emitter.send(org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event().name("message").data(json));
|
||||
if (this.emitter != null) {
|
||||
this.emitter.send(org.springframework.web.servlet.mvc.method.annotation.SseEmitter.event().name("message").data(json));
|
||||
log.info("Message successfully sent to SSE emitter");
|
||||
|
||||
// Custom 프로토콜: 1회 요청당 1응답 후 종료 (스트림을 닫아버림)
|
||||
// 클라이언트가 한 번의 POST 후 응답을 받고 연결을 끊기 때문
|
||||
// this.emitter.complete(); // MCP 표준 클라이언트 지원을 위해 스트림 강제 종료 제거
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error sending message to SSE emitter", e);
|
||||
emitter.completeWithError(e);
|
||||
if (this.emitter != null) {
|
||||
this.emitter.completeWithError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(emitter::complete);
|
||||
return Mono.fromRunnable(() -> {
|
||||
if (this.emitter != null) {
|
||||
this.emitter.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1,4 +1,21 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.sync;
|
||||
package io.shinhanlife.dap.mcg.sync;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.sync
|
||||
* @className DynamicMcpController
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -9,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import io.shinhanlife.dap.mcg.sync.CustomWebMvcSseServerTransportProvider;
|
||||
|
||||
@RestController
|
||||
public class DynamicMcpController {
|
||||
@@ -19,24 +37,6 @@ public class DynamicMcpController {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
@PostMapping("/mcp")
|
||||
public SseEmitter handleSse1() {
|
||||
System.out.println("11111111111");
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/initialize")
|
||||
public SseEmitter handleSse2() {
|
||||
System.out.println("2222222222");
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping("/mcp/sse/initialize")
|
||||
public SseEmitter handleSse3() {
|
||||
System.out.println("33333333333333");
|
||||
return null;
|
||||
}
|
||||
|
||||
@GetMapping("/mcp/sse/{category}")
|
||||
public SseEmitter handleSse(@PathVariable("category") String category) {
|
||||
CustomWebMvcSseServerTransportProvider transport = manager.getTransport(category);
|
||||
@@ -70,18 +70,33 @@ public class DynamicMcpController {
|
||||
return ResponseEntity.badRequest().body("Unknown category: " + category);
|
||||
}
|
||||
|
||||
if (sessionId == null || sessionId.isEmpty()) {
|
||||
// 새 세션 생성 (initialize 요청)
|
||||
String newSessionId = java.util.UUID.randomUUID().toString();
|
||||
SseEmitter emitter = transport.handleCustomSse(newSessionId, body);
|
||||
boolean isNew = (sessionId == null || sessionId.isEmpty());
|
||||
String activeSessionId = isNew ? java.util.UUID.randomUUID().toString() : sessionId;
|
||||
|
||||
if (!isNew && !transport.hasSession(activeSessionId)) {
|
||||
// 클라이언트가 보낸 세션 ID가 만료되었거나 존재하지 않는 경우 (Warm Pool 스펙: 404 Not Found 반환)
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter = transport.handleCustomSse(activeSessionId, body);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Mcp-Session-Id", newSessionId)
|
||||
.header("Mcp-Session-Id", activeSessionId)
|
||||
.body(emitter);
|
||||
} else {
|
||||
// 기존 세션 메시지 전송 (tools/call 등)
|
||||
transport.handleMessage(sessionId, body);
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.DeleteMapping("/mcp/sse/{category}")
|
||||
public ResponseEntity<Void> handleDeleteSse(@PathVariable("category") String category) {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/.well-known/oauth-protected-resource")
|
||||
public ResponseEntity<java.util.Map<String, Object>> handleOauthProtectedResource() {
|
||||
return ResponseEntity.ok().body(java.util.Map.of());
|
||||
}
|
||||
|
||||
@GetMapping("/.well-known/oauth-protected-resource/**")
|
||||
public ResponseEntity<java.util.Map<String, Object>> handleOauthProtectedResourceWildcard() {
|
||||
return ResponseEntity.ok().body(java.util.Map.of());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,25 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.sync;
|
||||
package io.shinhanlife.dap.mcg.sync;
|
||||
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.sync
|
||||
* @className DynamicMcpServerManager
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpSyncServer;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.sync.CustomWebMvcSseServerTransportProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -36,7 +53,9 @@ public class DynamicMcpServerManager {
|
||||
getOrCreateServer("payment");
|
||||
getOrCreateServer("sms");
|
||||
getOrCreateServer("email");
|
||||
getOrCreateServer("other");
|
||||
getOrCreateServer("oth");
|
||||
getOrCreateServer("sample");
|
||||
getOrCreateServer("smp"); // smp 카테고리도 기본적으로 항상 열어두도록 추가
|
||||
}
|
||||
|
||||
private McpSyncServer getOrCreateServer(String categoryKey) {
|
||||
@@ -50,8 +69,8 @@ public class DynamicMcpServerManager {
|
||||
CustomWebMvcSseServerTransportProvider transport = new CustomWebMvcSseServerTransportProvider(ssePath, msgPath, objectMapper);
|
||||
|
||||
McpSyncServer newServer = McpServer.sync(transport)
|
||||
.serverInfo("AXHUB-Gateway-" + key, "1.0.0")
|
||||
.capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.builder().tools(true).build())
|
||||
.serverInfo("dap-was-" + key, "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
categoryTransports.put(key, transport);
|
||||
@@ -1,10 +1,26 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.sync;
|
||||
package io.shinhanlife.dap.mcg.sync;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.service.ExecuteService;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.sync
|
||||
* @className RegistryMcpToolSpecificationFactory
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.lib.mcp.ToolMetadataMcpMapper;
|
||||
import io.shinhanlife.dap.mcg.service.ExecuteService;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.tool.result.ToolExecutionResult;
|
||||
import io.shinhanlife.dap.mcg.tool.result.ToolExecutionResult;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -33,11 +49,7 @@ public class RegistryMcpToolSpecificationFactory {
|
||||
* Registry Entry 하나를 MCP SDK의 stateless sync Tool specification으로 변환합니다.
|
||||
*/
|
||||
public McpServerFeatures.SyncToolSpecification create(ToolMetadata entry) {
|
||||
McpSchema.Tool tool = McpSchema.Tool.builder()
|
||||
.name(entry.getName())
|
||||
.description(description(entry))
|
||||
.inputSchema(inputSchema(entry))
|
||||
.build();
|
||||
McpSchema.Tool tool = ToolMetadataMcpMapper.toTool(entry);
|
||||
|
||||
return McpServerFeatures.SyncToolSpecification.builder()
|
||||
.tool(tool)
|
||||
@@ -126,6 +138,20 @@ public class RegistryMcpToolSpecificationFactory {
|
||||
return schema;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private McpSchema.JsonSchema toJsonSchema(Map<String, Object> schema) {
|
||||
return new McpSchema.JsonSchema(
|
||||
String.valueOf(schema.getOrDefault("type", "object")),
|
||||
schema.get("properties") instanceof Map<?, ?> properties
|
||||
? (Map<String, Object>) properties : Map.of(),
|
||||
schema.get("required") instanceof List<?> required
|
||||
? (List<String>) required : List.of(),
|
||||
schema.get("additionalProperties") instanceof Boolean additionalProperties
|
||||
? additionalProperties : Boolean.TRUE,
|
||||
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
|
||||
schema.get("definitions") instanceof Map<?, ?> definitions
|
||||
? (Map<String, Object>) definitions : Map.of());
|
||||
}
|
||||
private String description(ToolMetadata entry) {
|
||||
return entry.getDescription() == null || entry.getDescription().isBlank()
|
||||
? entry.getName() + " Tool"
|
||||
@@ -1,7 +1,7 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.sync;
|
||||
package io.shinhanlife.dap.mcg.sync;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.registry.RedisRegistryService;
|
||||
import io.modelcontextprotocol.server.McpSyncServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -19,16 +19,16 @@ import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.biz.mcp.gateway.sync
|
||||
* @package io.shinhanlife.dap.mcg.sync
|
||||
* @className RegistryMcpToolSynchronizer
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 김형식
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@@ -1,7 +1,24 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.tool.large;
|
||||
package io.shinhanlife.dap.mcg.tool.large;
|
||||
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.config.AgentResponseBudgetProperties;
|
||||
import io.shinhanlife.dap.biz.mcp.gateway.guardrail.SensitiveDataMasker;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcg.tool.large
|
||||
* @className AgentResponseBudgetService
|
||||
* @description AX HUB 시스템 처리 클래스
|
||||
* @author 0986406
|
||||
* @create 2026.09.01
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 0986406 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
import io.shinhanlife.dap.mcg.config.AgentResponseBudgetProperties;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -12,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) {
|
||||
@@ -91,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);
|
||||
}
|
||||
@@ -99,9 +113,9 @@ public class AgentResponseBudgetService {
|
||||
int copied = 0;
|
||||
int originalFields = 0;
|
||||
|
||||
java.util.Iterator<java.util.Map.Entry<String, JsonNode>> fields = masked.fields();
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = masked.fields();
|
||||
while (fields.hasNext()) {
|
||||
java.util.Map.Entry<String, JsonNode> entry = fields.next();
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
originalFields++;
|
||||
if (copied >= properties.maxFieldsPerItem()) {
|
||||
continue;
|
||||
@@ -135,9 +149,9 @@ public class AgentResponseBudgetService {
|
||||
ObjectNode source = (ObjectNode) item;
|
||||
ObjectNode limited = json.createObjectNode();
|
||||
|
||||
java.util.Iterator<java.util.Map.Entry<String, JsonNode>> fields = source.fields();
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = source.fields();
|
||||
while (fields.hasNext()) {
|
||||
java.util.Map.Entry<String, JsonNode> entry = fields.next();
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
JsonNode value = entry.getValue();
|
||||
if (value.isTextual()) {
|
||||
limited.put(entry.getKey(), truncateText(value.asText()));
|
||||
@@ -164,7 +178,7 @@ public class AgentResponseBudgetService {
|
||||
fallback.put("maxItemBytes", properties.maxItemBytes());
|
||||
if (item.isObject()) {
|
||||
ArrayNode fieldNames = json.createArrayNode();
|
||||
java.util.Iterator<String> fieldNamesIter = item.fieldNames();
|
||||
Iterator<String> fieldNamesIter = item.fieldNames();
|
||||
while (fieldNamesIter.hasNext()) {
|
||||
fieldNames.add(fieldNamesIter.next());
|
||||
}
|
||||
@@ -188,9 +202,9 @@ public class AgentResponseBudgetService {
|
||||
private ObjectNode copyWithout(ObjectNode source, String... excludedFields) {
|
||||
ObjectNode copy = json.createObjectNode();
|
||||
|
||||
java.util.Iterator<java.util.Map.Entry<String, JsonNode>> fields = source.fields();
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = source.fields();
|
||||
while (fields.hasNext()) {
|
||||
java.util.Map.Entry<String, JsonNode> entry = fields.next();
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
if (!excluded(entry.getKey(), excludedFields)) {
|
||||
copy.set(entry.getKey(), entry.getValue());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user