Compare commits
10 Commits
11f94cf721
...
017812cd29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
017812cd29 | ||
|
|
97a56efddf | ||
|
|
39c59be5a2 | ||
|
|
ccabd1187b | ||
|
|
3e553441ba | ||
|
|
b60b933a08 | ||
|
|
8cbdd2ecad | ||
|
|
6662641903 | ||
|
|
0e27937687 | ||
|
|
45e4332adb |
39
README.md
39
README.md
@@ -144,7 +144,7 @@ UI에서 사용하는 Tailwind CSS와 Chart.js는 `dap-gateway/src/main/resource
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "cmm_claim_schema_search",
|
||||
"name": "cmm_claim_search",
|
||||
"arguments": {
|
||||
"claimNo": "CLM2026070100120"
|
||||
}
|
||||
@@ -171,7 +171,7 @@ Tool 함수명은 아래 4단계 규칙을 사용합니다.
|
||||
|
||||
```text
|
||||
pod_domain_service_action
|
||||
예: cmm_claim_schema_search
|
||||
예: cmm_claim_search
|
||||
```
|
||||
|
||||
- `pod`: Tool Pod 식별자 (`oth`, `sms` 등)
|
||||
@@ -269,3 +269,38 @@ Tool 관련 공통 기능은 `dap-was-*` 모듈명만 기준으로 동작합니
|
||||
- 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 이미지 생성 전에 빌드가 실패합니다.
|
||||
|
||||
10
build.gradle
10
build.gradle
@@ -89,9 +89,19 @@ tasks.register('validateMcpToolNames', JavaExec) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ dependencies {
|
||||
// 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'
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -24,15 +24,37 @@ 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");
|
||||
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 {
|
||||
@@ -68,7 +90,7 @@ public class ScaffoldingController {
|
||||
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.getOrDefault("httpApiName", "sample");
|
||||
String httpApiName = req.get("httpApiName");
|
||||
String inputSchemaResource = req.get("inputSchemaResource");
|
||||
String outputSchemaResource = req.get("outputSchemaResource");
|
||||
List<ToolScaffolder.FieldDefinition> inputFields = parseFields(req.get("inputFields"));
|
||||
@@ -76,13 +98,90 @@ public class ScaffoldingController {
|
||||
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);
|
||||
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("/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}]}
|
||||
Allowed type values: String, Integer, Long, Double, Boolean, BigDecimal.
|
||||
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}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]}
|
||||
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.
|
||||
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 {
|
||||
@@ -122,6 +221,138 @@ public class ScaffoldingController {
|
||||
if (source == null || source.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return new ObjectMapper().readValue(source, new TypeReference<List<ToolScaffolder.FieldDefinition>>() { });
|
||||
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()))
|
||||
.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());
|
||||
}
|
||||
if (!names.add(field.name())) {
|
||||
throw new IllegalArgumentException("AI가 중복 필드명을 생성했습니다: " + field.name());
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package io.shinhanlife.dap.mcg.sync;
|
||||
* </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;
|
||||
@@ -48,18 +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(toJsonSchema(inputSchema(entry)))
|
||||
.annotations(new McpSchema.ToolAnnotations(
|
||||
entry.getDisplayName(),
|
||||
entry.getReadOnlyHint(),
|
||||
entry.getDestructiveHint(),
|
||||
entry.getIdempotentHint(),
|
||||
entry.getOpenWorldHint(),
|
||||
null))
|
||||
.build();
|
||||
McpSchema.Tool tool = ToolMetadataMcpMapper.toTool(entry);
|
||||
|
||||
return McpServerFeatures.SyncToolSpecification.builder()
|
||||
.tool(tool)
|
||||
|
||||
@@ -36,6 +36,7 @@ mcp:
|
||||
fallback:
|
||||
default-url: http://localhost:8084
|
||||
routes:
|
||||
cmm_memo_retriever: http://localhost:8082
|
||||
sms: http://localhost:8082
|
||||
email: http://localhost:8083
|
||||
payment: http://localhost:8085
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
package io.shinhanlife.dap.biz.mcp.gateway.sync;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.mcg.sync.RegistryMcpToolSpecificationFactory;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RegistryMcpToolSpecificationFactoryTest {
|
||||
@@ -15,7 +18,15 @@ class RegistryMcpToolSpecificationFactoryTest {
|
||||
void exposesMetadataBehaviorHintsInMcpToolSpecification() {
|
||||
ToolMetadata metadata = ToolMetadata.builder()
|
||||
.name("customer_lookup")
|
||||
.displayName("고객 조회")
|
||||
.description("Looks up customer information")
|
||||
.displayDescription("고객 기본 정보 조회")
|
||||
.semver("1.2.0")
|
||||
.categoryKey("cus")
|
||||
.exampleQueries(List.of("고객 10001을 조회해줘"))
|
||||
.tags(List.of("customer", "search"))
|
||||
.ownerOrg("CUSTOMER_TEAM")
|
||||
.outputSchema(Map.of("type", "object", "properties", Map.of()))
|
||||
.readOnlyHint(true)
|
||||
.destructiveHint(false)
|
||||
.idempotentHint(true)
|
||||
@@ -29,5 +40,9 @@ class RegistryMcpToolSpecificationFactoryTest {
|
||||
assertFalse(tool.annotations().destructiveHint());
|
||||
assertTrue(tool.annotations().idempotentHint());
|
||||
assertFalse(tool.annotations().openWorldHint());
|
||||
assertEquals("고객 조회", tool.title());
|
||||
assertEquals("object", tool.outputSchema().get("type"));
|
||||
assertEquals("1.2.0", tool.meta().get("version"));
|
||||
assertEquals("CUSTOMER_TEAM", tool.meta().get("owner_org"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentGeneratorControllerTest {
|
||||
|
||||
@Mock
|
||||
private DocumentGeneratorService service;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(new DocumentGeneratorController(service)).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsGeneratedProgramWorkbook() throws Exception {
|
||||
byte[] workbook = "xlsx-content".getBytes(StandardCharsets.UTF_8);
|
||||
when(service.generateProgram(any())).thenReturn(new GeneratedDocument(
|
||||
"고객정보 조회 Tool_프로그램정의서_v1.0_20260811.xlsx", workbook));
|
||||
DocumentGenerationRequest request = new DocumentGenerationRequest(
|
||||
ToolMetadata.builder().uid("tool-uid").name("oth.cmm.customer.detail").build(),
|
||||
"1.0", true, true, true);
|
||||
|
||||
mockMvc.perform(post("/mcp/api/v1/admin/documents/program")
|
||||
.contentType("application/json")
|
||||
.content(objectMapper.writeValueAsBytes(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
|
||||
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
|
||||
org.hamcrest.Matchers.containsString("attachment")))
|
||||
.andExpect(content().bytes(workbook));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.lib.dto.OperationType;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DocumentGeneratorServiceTest {
|
||||
|
||||
private DocumentGeneratorService service;
|
||||
private ToolMetadata tool;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-08-11T03:00:00Z"), ZoneId.of("Asia/Seoul"));
|
||||
service = new DocumentGeneratorService(new ObjectMapper(), clock);
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
properties.put("customerId", Map.of(
|
||||
"type", "string",
|
||||
"description", "고객 ID",
|
||||
"pattern", "\\d{8}",
|
||||
"examples", List.of("12345678")));
|
||||
properties.put("pageSize", Map.of(
|
||||
"type", "integer",
|
||||
"description", "페이지 크기",
|
||||
"default", 20));
|
||||
|
||||
tool = ToolMetadata.builder()
|
||||
.uid("ebf042d9-2203-3992-9237-634a58515223")
|
||||
.semver("1.0.0")
|
||||
.displayName("테스트 고객조회 Tool")
|
||||
.name("oth.cmm.customer.detail")
|
||||
.description("테스트 고객의 상세정보를 조회합니다.")
|
||||
.categoryKey("cmm")
|
||||
.endpoint("http://was-oth:8084/mcp/oth.cmm.customer.detail")
|
||||
.podUrl("http://was-oth:8084")
|
||||
.integrationType("REST")
|
||||
.mciServiceId("CUST_001")
|
||||
.operationType(OperationType.READ)
|
||||
.timeoutMillis(5000L)
|
||||
.visible(true)
|
||||
.parametersSchema(Map.of(
|
||||
"type", "object",
|
||||
"properties", properties,
|
||||
"required", List.of("customerId")))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsSelectedProgramSheetsFromTemplate() throws Exception {
|
||||
GeneratedDocument document = service.generateProgram(
|
||||
new DocumentGenerationRequest(tool, "1.0", true, true, true));
|
||||
|
||||
assertThat(document.fileName())
|
||||
.isEqualTo("테스트 고객조회 Tool_프로그램정의서_v1.0_20260811.xlsx");
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
|
||||
assertThat(workbook.getSheetName(0)).isEqualTo("프로그램정의서");
|
||||
assertThat(workbook.getSheetName(1)).isEqualTo("처리설계");
|
||||
assertThat(workbook.getSheetName(2)).isEqualTo("개정이력");
|
||||
assertThat(workbook.getSheet("프로그램정의서").getRow(4).getCell(1).getStringCellValue())
|
||||
.isEqualTo("테스트 고객조회 Tool");
|
||||
assertThat(workbook.getSheet("처리설계").getRow(0).getCell(0).getStringCellValue())
|
||||
.isEqualTo("처리설계");
|
||||
assertThat(workbook.getSheet("개정이력").getRow(3).getCell(0).getStringCellValue())
|
||||
.isEqualTo("1.0");
|
||||
assertThat(workbook.getNumCellStyles()).isGreaterThan(8);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesUncheckedProgramSheets() throws Exception {
|
||||
GeneratedDocument document = service.generateProgram(
|
||||
new DocumentGenerationRequest(tool, "v2.0", false, false, true));
|
||||
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
|
||||
assertThat(workbook.getSheetName(0)).isEqualTo("개정이력");
|
||||
assertThat(workbook.getSheetAt(0).getRow(3).getCell(0).getStringCellValue()).isEqualTo("2.0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsRequestAndResponseInterfaceSheetsFromTemplate() throws Exception {
|
||||
GeneratedDocument document = service.generateInterface(
|
||||
new DocumentGenerationRequest(tool, "1.0", false, false, false));
|
||||
|
||||
assertThat(document.fileName())
|
||||
.isEqualTo("테스트 고객조회 Tool_인터페이스정의서_v1.0_20260811.xlsx");
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(2);
|
||||
assertThat(workbook.getSheetName(0)).isEqualTo("Request In");
|
||||
assertThat(workbook.getSheetName(1)).isEqualTo("Response Out");
|
||||
assertThat(workbook.getSheet("Request In").getRow(1).getCell(2).getStringCellValue())
|
||||
.isEqualTo("테스트 고객조회 Tool");
|
||||
assertThat(workbook.getSheet("Request In").getRow(3).getCell(3).getStringCellValue())
|
||||
.isEqualTo("http://was-oth:8084/mcp/oth.cmm.customer.detail");
|
||||
assertThat(workbook.getSheet("Request In").getRow(8).getCell(5).getStringCellValue())
|
||||
.isEqualTo("customerId");
|
||||
assertThat(workbook.getSheet("Request In").getRow(8).getCell(7).getStringCellValue())
|
||||
.isEqualTo("8");
|
||||
assertThat(workbook.getSheet("Request In").getRow(32).getCell(2).getStringCellValue())
|
||||
.contains("customerId", "12345678");
|
||||
assertThat(workbook.getSheet("Response Out").getRow(8).getCell(5).getStringCellValue())
|
||||
.isEqualTo("resultData");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProgramRequestWithoutSelectedSheet() {
|
||||
assertThatThrownBy(() -> service.generateProgram(
|
||||
new DocumentGenerationRequest(tool, "1.0", false, false, false)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("한 개 이상의 시트");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.shinhanlife.dap.mcg.presentation;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class ScaffoldingControllerToolDraftTest {
|
||||
|
||||
@Test
|
||||
void toolDraftEndpointIsAvailable() throws Exception {
|
||||
ChatClient.Builder builder = mock(ChatClient.Builder.class);
|
||||
ChatClient chatClient = mock(ChatClient.class);
|
||||
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
|
||||
ChatClient.CallResponseSpec responseSpec = mock(ChatClient.CallResponseSpec.class);
|
||||
when(builder.build()).thenReturn(chatClient);
|
||||
when(chatClient.prompt()).thenReturn(requestSpec);
|
||||
when(requestSpec.user(anyString())).thenReturn(requestSpec);
|
||||
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
|
||||
when(requestSpec.call()).thenReturn(responseSpec);
|
||||
when(responseSpec.content()).thenReturn("""
|
||||
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","functionDescription":"고객 계약의 현재 상태를 조회한다.","displayDescription":"고객 계약 상태 조회","whenToUse":"고객번호로 계약 상태 확인을 요청할 때 사용한다.","whenNotToUse":"계약 변경 또는 해지를 요청할 때는 사용하지 않는다.","ioLimits":"고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다.","exampleQueries":["고객 C123의 계약 상태를 알려줘","C123 계약이 정상인지 확인해줘","고객번호 C123 계약 조회해줘"],"tags":["contract","status","search"],"ownerOrg":"MCP_TOOL","inputFields":[{"name":"customerId","type":"String","description":"고객번호","example":"C123","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","example":"SUCCESS","required":true}]}
|
||||
""");
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
new ScaffoldingController(builder, new ObjectMapper()))
|
||||
.setMessageConverters(new MappingJackson2HttpMessageConverter())
|
||||
.build();
|
||||
|
||||
mockMvc.perform(post("/api/v1/scaffold/tool-draft")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"description\":\"고객번호로 계약 상태를 조회\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.baseName").value("CustomerContractStatus"))
|
||||
.andExpect(jsonPath("$.functionDescription").value("고객 계약의 현재 상태를 조회한다."))
|
||||
.andExpect(jsonPath("$.displayDescription").value("고객 계약 상태 조회"))
|
||||
.andExpect(jsonPath("$.whenToUse").value("고객번호로 계약 상태 확인을 요청할 때 사용한다."))
|
||||
.andExpect(jsonPath("$.whenNotToUse").value("계약 변경 또는 해지를 요청할 때는 사용하지 않는다."))
|
||||
.andExpect(jsonPath("$.ioLimits").value("고객번호 한 건을 입력받아 계약 상태 한 건을 반환한다."))
|
||||
.andExpect(jsonPath("$.exampleQueries[0]").value("고객 C123의 계약 상태를 알려줘"))
|
||||
.andExpect(jsonPath("$.tags[0]").value("contract"))
|
||||
.andExpect(jsonPath("$.ownerOrg").value("MCP_TOOL"))
|
||||
.andExpect(jsonPath("$.inputFields[0].name").value("customerId"));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ dependencies {
|
||||
|
||||
// MCI XML 전문, JSON Tool Schema/Manifest 처리에 사용합니다.
|
||||
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
|
||||
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
|
||||
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||
api 'com.networknt:json-schema-validator:3.0.0'
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package io.shinhanlife.dap.lib.adapter.test;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Local HTTP mock server for scaffolded HTTP Tools.
|
||||
*
|
||||
* <p>Each Tool Pod returns the JSON generated under
|
||||
* {@code src/main/resources/mock-responses/{toolName}.json}. It is enabled only
|
||||
* when {@code axhub.mock.http.enabled=true}, which the HTTP Scaffold adds to local configuration.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(prefix = "axhub.mock.http", name = "enabled", havingValue = "true")
|
||||
@RequestMapping("/api")
|
||||
public class MockEimsHttpServer {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping("/mock/http/{toolName:[a-z0-9_-]+}")
|
||||
public ResponseEntity<JsonNode> mockToolHttpResponse(
|
||||
@PathVariable String toolName,
|
||||
@RequestBody(required = false) JsonNode request) {
|
||||
ClassPathResource resource = new ClassPathResource("mock-responses/" + toolName + ".json");
|
||||
if (!resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
try {
|
||||
log.info("[MockEimsHttpServer] HTTP mock request. toolName={}, body={}", toolName, request);
|
||||
return ResponseEntity.ok(objectMapper.readTree(resource.getInputStream()));
|
||||
} catch (Exception e) {
|
||||
log.warn("[MockEimsHttpServer] Unable to read mock response. toolName={}", toolName, e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/gateway")
|
||||
public ResponseEntity<?> mockEimsReceiver(
|
||||
@RequestHeader(value = "X-Trace-Id", required = false) String traceId,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
String interfaceId = String.valueOf(request.getOrDefault("interfaceId", ""));
|
||||
log.info("[MockEimsHttpServer] Legacy gateway mock request. traceId={}, interfaceId={}", traceId, interfaceId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "404",
|
||||
"message", "MOCK data is not defined for interfaceId: " + interfaceId));
|
||||
}
|
||||
}
|
||||
@@ -47,16 +47,6 @@ public class AxhubHttpComponent {
|
||||
return execute(api, uri, inputDto, responseBodyClass, timeout);
|
||||
}
|
||||
|
||||
/** Backward-compatible enum overload. */
|
||||
public <T, R> R call(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass) {
|
||||
return call(domain.getCode(), uri, inputDto, responseBodyClass, 0);
|
||||
}
|
||||
|
||||
/** Backward-compatible enum overload. */
|
||||
public <T, R> R call(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass, int timeout) {
|
||||
return call(domain.getCode(), uri, inputDto, responseBodyClass, timeout);
|
||||
}
|
||||
|
||||
/** Calls the configured URL only when the target is marked as a business Pod. */
|
||||
public <T, R> R callBizPod(String apiName, T inputDto, Class<R> responseBodyClass) {
|
||||
AxhubHttpProperties.ApiDefinition api = resolveApi(apiName);
|
||||
@@ -66,15 +56,6 @@ public class AxhubHttpComponent {
|
||||
return execute(api, "", inputDto, responseBodyClass, 0);
|
||||
}
|
||||
|
||||
/** Backward-compatible enum overload. */
|
||||
public <T, R> R callBizPod(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass) {
|
||||
AxhubHttpProperties.ApiDefinition api = resolveApi(domain.getCode());
|
||||
if (!api.bizPod()) {
|
||||
throw new IllegalArgumentException("Configured API is not a business Pod: " + domain.getCode());
|
||||
}
|
||||
return execute(api, uri, inputDto, responseBodyClass, 0);
|
||||
}
|
||||
|
||||
private <T, R> R execute(AxhubHttpProperties.ApiDefinition api, String uri, T inputDto,
|
||||
Class<R> responseBodyClass, int timeout) {
|
||||
HttpHeader header = createHeader(api, timeout);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package io.shinhanlife.dap.lib.integration.http.component;
|
||||
|
||||
/** Registered outbound HTTP API domains. Add a domain only after its endpoint is configured. */
|
||||
public enum AxhubHttpDomain {
|
||||
SAMPLE("sample");
|
||||
|
||||
private final String code;
|
||||
|
||||
AxhubHttpDomain(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public record ToolManifestItem(
|
||||
String title,
|
||||
String description,
|
||||
Map<String, Object> inputSchema,
|
||||
Map<String, Object> outputSchema,
|
||||
ToolManifestAnnotations annotations,
|
||||
@JsonProperty("_meta") ToolManifestMeta meta) {
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
package io.shinhanlife.dap.lib.manifest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Operational metadata exposed by the Tool Service manifest. */
|
||||
public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) {
|
||||
public record ToolManifestMeta(
|
||||
String version,
|
||||
long timeoutMillis,
|
||||
boolean enabled,
|
||||
List<String> exampleQueries,
|
||||
List<String> tags,
|
||||
String legacyInterfaceId,
|
||||
List<String> requiredEnvKeys,
|
||||
String ownerOrg) {
|
||||
}
|
||||
@@ -61,12 +61,14 @@ public class ToolManifestService {
|
||||
? tool.getName() : tool.getDisplayName();
|
||||
Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema();
|
||||
return new ToolManifestItem(
|
||||
tool.getName(), endpoint(tool), title, tool.getDescription(), schema,
|
||||
tool.getName(), endpoint(tool), title, tool.getDescription(), schema, tool.getOutputSchema(),
|
||||
new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
|
||||
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
|
||||
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
|
||||
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(),
|
||||
tool.getEnabled() == null || tool.getEnabled()));
|
||||
tool.getEnabled() == null || tool.getEnabled(),
|
||||
defaultList(tool.getExampleQueries()), defaultList(tool.getTags()),
|
||||
tool.getMciServiceId(), defaultList(tool.getRequiredEnvKeys()), tool.getOwnerOrg()));
|
||||
}
|
||||
|
||||
private void validate(List<ToolManifestItem> tools) {
|
||||
@@ -128,4 +130,8 @@ public class ToolManifestService {
|
||||
private String defaultString(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private List<String> defaultList(List<String> value) {
|
||||
return value == null ? List.of() : List.copyOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.shinhanlife.dap.lib.mcp;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Registry 메타데이터를 MCP SDK Tool 명세로 일관되게 변환합니다. */
|
||||
public final class ToolMetadataMcpMapper {
|
||||
private ToolMetadataMcpMapper() {
|
||||
}
|
||||
|
||||
public static McpSchema.Tool toTool(ToolMetadata metadata) {
|
||||
McpSchema.Tool.Builder builder = McpSchema.Tool.builder()
|
||||
.name(metadata.getName())
|
||||
.title(defaultText(metadata.getDisplayName(), metadata.getName()))
|
||||
.description(defaultText(metadata.getDescription(), metadata.getName() + " Tool"))
|
||||
.inputSchema(toJsonSchema(metadata.getParametersSchema()))
|
||||
.annotations(new McpSchema.ToolAnnotations(
|
||||
metadata.getDisplayName(), metadata.getReadOnlyHint(), metadata.getDestructiveHint(),
|
||||
metadata.getIdempotentHint(), metadata.getOpenWorldHint(), null))
|
||||
.meta(meta(metadata));
|
||||
if (metadata.getOutputSchema() != null && !metadata.getOutputSchema().isEmpty()) {
|
||||
builder.outputSchema(metadata.getOutputSchema());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static Map<String, Object> meta(ToolMetadata metadata) {
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
put(meta, "version", metadata.getSemver());
|
||||
put(meta, "category_key", metadata.getCategoryKey());
|
||||
put(meta, "display_description", metadata.getDisplayDescription());
|
||||
put(meta, "example_queries", metadata.getExampleQueries());
|
||||
put(meta, "tags", metadata.getTags());
|
||||
put(meta, "legacy_interface_id", metadata.getMciServiceId());
|
||||
put(meta, "required_env_keys", metadata.getRequiredEnvKeys());
|
||||
put(meta, "owner_org", metadata.getOwnerOrg());
|
||||
return Map.copyOf(meta);
|
||||
}
|
||||
|
||||
private static void put(Map<String, Object> target, String key, Object value) {
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
target.put(key, text);
|
||||
} else if (value instanceof List<?> list && !list.isEmpty()) {
|
||||
target.put(key, List.copyOf(list));
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultText(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static McpSchema.JsonSchema toJsonSchema(Map<String, Object> source) {
|
||||
Map<String, Object> schema = source == null ? emptySchema() : source;
|
||||
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 static Map<String, Object> emptySchema() {
|
||||
return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false);
|
||||
}
|
||||
}
|
||||
@@ -39,18 +39,7 @@ public class ToolPodMcpToolSynchronizer {
|
||||
}
|
||||
|
||||
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
|
||||
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
|
||||
.name(tool.getName())
|
||||
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
|
||||
.inputSchema(toJsonSchema(tool.getParametersSchema()))
|
||||
.annotations(new McpSchema.ToolAnnotations(
|
||||
tool.getDisplayName(),
|
||||
tool.getReadOnlyHint(),
|
||||
tool.getDestructiveHint(),
|
||||
tool.getIdempotentHint(),
|
||||
tool.getOpenWorldHint(),
|
||||
null))
|
||||
.build();
|
||||
McpSchema.Tool mcpTool = ToolMetadataMcpMapper.toTool(tool);
|
||||
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
|
||||
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
@@ -29,14 +31,15 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -49,7 +52,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
@Component
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBean(McpToolExecutionService.class)
|
||||
public class ToolRegistryHeartbeatSender {
|
||||
|
||||
@@ -58,6 +60,23 @@ public class ToolRegistryHeartbeatSender {
|
||||
private final McpProperties mcpProperties;
|
||||
private final RestClient restClient = RestClient.create();
|
||||
private final ToolSchemaResolver toolSchemaResolver;
|
||||
private final ToolDefinitionRepository toolDefinitionRepository;
|
||||
|
||||
@Autowired
|
||||
public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper,
|
||||
McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver,
|
||||
@Nullable ToolDefinitionRepository toolDefinitionRepository) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.objectMapper = objectMapper;
|
||||
this.mcpProperties = mcpProperties;
|
||||
this.toolSchemaResolver = toolSchemaResolver;
|
||||
this.toolDefinitionRepository = toolDefinitionRepository;
|
||||
}
|
||||
|
||||
public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper,
|
||||
McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver) {
|
||||
this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null);
|
||||
}
|
||||
|
||||
@Value("${axhub.gateway.url:http://localhost:8081}")
|
||||
private String gatewayUrl;
|
||||
@@ -143,11 +162,18 @@ public class ToolRegistryHeartbeatSender {
|
||||
// TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction
|
||||
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType);
|
||||
meta.setParametersSchema(finalSchema);
|
||||
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(
|
||||
functionAnnotation, method.getReturnType(), hintAnnotation);
|
||||
if (!outputSchema.isEmpty()) {
|
||||
meta.setOutputSchema(outputSchema);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate schema for {}", subToolName, e);
|
||||
}
|
||||
}
|
||||
|
||||
enrichWithDefinition(meta, rawSubToolName, hintAnnotation);
|
||||
|
||||
if (isRegister) {
|
||||
registeredTools.add(meta);
|
||||
}
|
||||
@@ -158,6 +184,45 @@ public class ToolRegistryHeartbeatSender {
|
||||
}
|
||||
}
|
||||
|
||||
private void enrichWithDefinition(ToolMetadata meta, String rawToolName, ToolHint hintAnnotation) {
|
||||
if (toolDefinitionRepository == null) {
|
||||
return;
|
||||
}
|
||||
toolDefinitionRepository.findByName(rawToolName)
|
||||
.ifPresent(definition -> applyDefinition(meta, definition, hintAnnotation));
|
||||
}
|
||||
|
||||
private void applyDefinition(ToolMetadata meta, ToolDefinition definition, ToolHint hintAnnotation) {
|
||||
meta.setDisplayName(definition.displayName());
|
||||
meta.setSemver(definition.version());
|
||||
meta.setCategoryKey(definition.categoryKey());
|
||||
meta.setFunctionDescription(definition.description().function());
|
||||
meta.setWhenToUse(definition.description().whenToUse());
|
||||
meta.setWhenNotToUse(definition.description().whenNotToUse());
|
||||
meta.setIoLimits(definition.description().ioLimits());
|
||||
meta.setDescription(String.join("\n", definition.description().function(),
|
||||
"사용 시점: " + definition.description().whenToUse(),
|
||||
"사용 제외: " + definition.description().whenNotToUse(),
|
||||
"입출력 제한: " + definition.description().ioLimits()));
|
||||
meta.setDisplayDescription(definition.displayDescription());
|
||||
meta.setExampleQueries(definition.exampleQueries());
|
||||
meta.setReadOnlyHint(definition.readOnly());
|
||||
meta.setDestructiveHint(definition.destructive());
|
||||
meta.setIdempotentHint(definition.idempotent());
|
||||
boolean explicitInputResource = hintAnnotation != null && !hintAnnotation.inputSchemaResource().isBlank();
|
||||
boolean explicitOutputResource = hintAnnotation != null && !hintAnnotation.outputSchemaResource().isBlank();
|
||||
if (!explicitInputResource) {
|
||||
meta.setParametersSchema(definition.parametersSchema());
|
||||
}
|
||||
if (!explicitOutputResource && definition.outputSchema() != null && !definition.outputSchema().isEmpty()) {
|
||||
meta.setOutputSchema(definition.outputSchema());
|
||||
}
|
||||
meta.setTags(definition.tags());
|
||||
meta.setMciServiceId(definition.legacyInterfaceId());
|
||||
meta.setRequiredEnvKeys(definition.requiredEnvKeys());
|
||||
meta.setOwnerOrg(definition.ownerOrg());
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 30000)
|
||||
public void sendHeartbeats() {
|
||||
if (registeredTools.isEmpty()) return;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** BC-DAB-STD-003 V17 Tool 정의 파일의 불변 모델입니다. */
|
||||
public record ToolDefinition(
|
||||
String name,
|
||||
@JsonProperty("display_name") String displayName,
|
||||
String version,
|
||||
@JsonProperty("category_key") String categoryKey,
|
||||
ToolDescription description,
|
||||
@JsonProperty("display_description") String displayDescription,
|
||||
@JsonProperty("example_queries") List<String> exampleQueries,
|
||||
@JsonProperty("read_only") Boolean readOnly,
|
||||
Boolean destructive,
|
||||
Boolean idempotent,
|
||||
@JsonProperty("parameters_schema") Map<String, Object> parametersSchema,
|
||||
@JsonProperty("output_schema") Map<String, Object> outputSchema,
|
||||
List<String> tags,
|
||||
@JsonProperty("legacy_interface_id") String legacyInterfaceId,
|
||||
@JsonProperty("required_env_keys") List<String> requiredEnvKeys,
|
||||
@JsonProperty("owner_org") String ownerOrg) {
|
||||
|
||||
public ToolDefinition withExampleQueries(List<String> queries) {
|
||||
return new ToolDefinition(name, displayName, version, categoryKey, description, displayDescription,
|
||||
queries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId,
|
||||
requiredEnvKeys, ownerOrg);
|
||||
}
|
||||
|
||||
public ToolDefinition withName(String value) {
|
||||
return new ToolDefinition(value, displayName, version, categoryKey, description, displayDescription,
|
||||
exampleQueries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId,
|
||||
requiredEnvKeys, ownerOrg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** classpath의 Tool 정의를 기동 시 한 번 읽어 name 기준으로 캐시합니다. */
|
||||
@Component
|
||||
public class ToolDefinitionRepository {
|
||||
public static final String DEFAULT_LOCATION = "classpath*:tool-definitions/**/*.yml";
|
||||
|
||||
private final Map<String, ToolDefinition> definitions;
|
||||
|
||||
@Autowired
|
||||
public ToolDefinitionRepository(ResourceLoader resourceLoader) {
|
||||
this(new ObjectMapper(new YAMLFactory()), resourceLoader, DEFAULT_LOCATION);
|
||||
}
|
||||
|
||||
public ToolDefinitionRepository(ObjectMapper yamlMapper, ResourceLoader resourceLoader, String location) {
|
||||
this(yamlMapper, new PathMatchingResourcePatternResolver(resourceLoader), location);
|
||||
}
|
||||
|
||||
private ToolDefinitionRepository(ObjectMapper yamlMapper, ResourcePatternResolver resolver, String location) {
|
||||
this.definitions = Collections.unmodifiableMap(load(yamlMapper, resolver, location));
|
||||
}
|
||||
|
||||
public Optional<ToolDefinition> findByName(String name) {
|
||||
return Optional.ofNullable(definitions.get(name));
|
||||
}
|
||||
|
||||
public Map<String, ToolDefinition> findAll() {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private Map<String, ToolDefinition> load(ObjectMapper mapper, ResourcePatternResolver resolver, String location) {
|
||||
Map<String, ToolDefinition> loaded = new LinkedHashMap<>();
|
||||
try {
|
||||
for (Resource resource : resolver.getResources(location)) {
|
||||
ToolDefinition definition = mapper.readValue(resource.getInputStream(), ToolDefinition.class);
|
||||
String source = resource.getDescription();
|
||||
ToolDefinitionValidator.validate(definition, source);
|
||||
ToolDefinition previous = loaded.putIfAbsent(definition.name(), definition);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Duplicate Tool definition name: " + definition.name());
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to load Tool definitions from " + location, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Tool 정의가 BC-DAB-STD-003 V17 필수 규칙을 만족하는지 검증합니다. */
|
||||
public final class ToolDefinitionValidator {
|
||||
private static final Pattern TOOL_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,63}$");
|
||||
|
||||
private ToolDefinitionValidator() {
|
||||
}
|
||||
|
||||
public static void validate(ToolDefinition definition, String source) {
|
||||
if (definition == null) {
|
||||
fail(source, "definition", "문서가 비어 있습니다");
|
||||
}
|
||||
if (isBlank(definition.name()) || !TOOL_NAME.matcher(definition.name()).matches()) {
|
||||
fail(source, "name", "^[a-z][a-z0-9_]{2,63}$ 형식이어야 합니다");
|
||||
}
|
||||
requireText(source, "display_name", definition.displayName());
|
||||
requireText(source, "version", definition.version());
|
||||
requireText(source, "category_key", definition.categoryKey());
|
||||
requireText(source, "display_description", definition.displayDescription());
|
||||
if (definition.description() == null) {
|
||||
fail(source, "description", "필수입니다");
|
||||
}
|
||||
requireText(source, "description.function", definition.description().function());
|
||||
requireText(source, "description.when_to_use", definition.description().whenToUse());
|
||||
requireText(source, "description.when_not_to_use", definition.description().whenNotToUse());
|
||||
requireText(source, "description.io_limits", definition.description().ioLimits());
|
||||
List<String> examples = definition.exampleQueries();
|
||||
if (examples == null || examples.size() < 3 || examples.size() > 10
|
||||
|| examples.stream().anyMatch(ToolDefinitionValidator::isBlank)) {
|
||||
fail(source, "example_queries", "비어 있지 않은 자연어 질의가 3~10개 필요합니다");
|
||||
}
|
||||
if (examples.stream().anyMatch(query -> query.contains(definition.name()))) {
|
||||
fail(source, "example_queries", "Tool name을 직접 포함할 수 없습니다");
|
||||
}
|
||||
if (definition.readOnly() == null || definition.destructive() == null || definition.idempotent() == null) {
|
||||
fail(source, "annotations", "read_only, destructive, idempotent는 필수입니다");
|
||||
}
|
||||
validateSchema(definition.parametersSchema(), source);
|
||||
if (definition.outputSchema() != null && !definition.outputSchema().isEmpty()) {
|
||||
validateSchema(definition.outputSchema(), source + " output_schema");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void validateSchema(Map<String, Object> schema, String source) {
|
||||
if (schema == null || !"object".equals(schema.get("type"))) {
|
||||
fail(source, "parameters_schema.type", "object여야 합니다");
|
||||
}
|
||||
if (!Boolean.FALSE.equals(schema.get("additionalProperties"))) {
|
||||
fail(source, "parameters_schema.additionalProperties", "false여야 합니다");
|
||||
}
|
||||
Object propertiesValue = schema.get("properties");
|
||||
if (!(propertiesValue instanceof Map<?, ?>)) {
|
||||
fail(source, "parameters_schema.properties", "object여야 합니다");
|
||||
}
|
||||
Map<?, ?> properties = (Map<?, ?>) propertiesValue;
|
||||
for (Map.Entry<?, ?> entry : properties.entrySet()) {
|
||||
if (!(entry.getValue() instanceof Map<?, ?> property)
|
||||
|| isBlank(String.valueOf(property.containsKey("description")
|
||||
? property.get("description") : ""))) {
|
||||
fail(source, "parameters_schema.properties." + entry.getKey() + ".description", "필수입니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String source, String field, String value) {
|
||||
if (isBlank(value)) {
|
||||
fail(source, field, "필수입니다");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static void fail(String source, String field, String message) {
|
||||
throw new IllegalStateException("Invalid Tool definition [" + source + "] " + field + ": " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/** LLM이 Tool 선택 여부를 판단할 때 사용하는 V17 목적 설명입니다. */
|
||||
public record ToolDescription(
|
||||
String function,
|
||||
@JsonProperty("when_to_use") String whenToUse,
|
||||
@JsonProperty("when_not_to_use") String whenNotToUse,
|
||||
@JsonProperty("io_limits") String ioLimits) {
|
||||
}
|
||||
@@ -47,6 +47,17 @@ public class ToolScaffolder {
|
||||
public record FieldDefinition(String name, String type, String description, String example, boolean required) {
|
||||
}
|
||||
|
||||
public record ToolDefinitionOptions(
|
||||
String functionDescription,
|
||||
String whenToUse,
|
||||
String whenNotToUse,
|
||||
String ioLimits,
|
||||
String displayDescription,
|
||||
List<String> exampleQueries,
|
||||
List<String> tags,
|
||||
String ownerOrg) {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
|
||||
@@ -117,17 +128,29 @@ public class ToolScaffolder {
|
||||
*/
|
||||
public static String scaffold(String baseName, String interfaceId, String title, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List<FieldDefinition> inputFields, List<FieldDefinition> outputFields) throws IOException {
|
||||
return scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, createDate,
|
||||
register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields, "sample");
|
||||
register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields,
|
||||
toKebabCase(toPascalCase(baseName)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Tool using an HTTP API name that is resolved from glow.communication.http.api-list.
|
||||
*/
|
||||
public static String scaffold(String baseName, String interfaceId, String title, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List<FieldDefinition> inputFields, List<FieldDefinition> outputFields, String httpApiName) throws IOException {
|
||||
return scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, createDate,
|
||||
register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields,
|
||||
httpApiName, null);
|
||||
}
|
||||
|
||||
public static String scaffold(String baseName, String interfaceId, String title, String description, String group,
|
||||
String routingType, String moduleName, String author, String createDate,
|
||||
boolean register, String clientSystemCode, String inputSchemaResource,
|
||||
String outputSchemaResource, List<FieldDefinition> inputFields,
|
||||
List<FieldDefinition> outputFields, String httpApiName,
|
||||
ToolDefinitionOptions definitionOptions) throws IOException {
|
||||
baseName = toPascalCase(baseName);
|
||||
title = title == null || title.isBlank() ? baseName : title.trim();
|
||||
description = description == null ? "" : description.trim();
|
||||
httpApiName = httpApiName == null || httpApiName.isBlank() ? "sample" : httpApiName.trim();
|
||||
httpApiName = httpApiName == null || httpApiName.isBlank() ? toKebabCase(baseName) : httpApiName.trim();
|
||||
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
|
||||
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
|
||||
|
||||
@@ -144,6 +167,7 @@ public class ToolScaffolder {
|
||||
String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json";
|
||||
String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json";
|
||||
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-schemas", group.toLowerCase()));
|
||||
Path definitionDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-definitions", group.toLowerCase()));
|
||||
String inputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + inputSchemaFileName;
|
||||
String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName;
|
||||
|
||||
@@ -853,12 +877,17 @@ public class ToolScaffolder {
|
||||
Path projectRoot = moduleRoot.getParent();
|
||||
Path wireMockBodyPath = projectRoot.resolve(Paths.get("mci-mock", "__files", toolName + ".json"));
|
||||
Path wireMockMappingPath = projectRoot.resolve(Paths.get("mci-mock", "mappings", toolName + ".json"));
|
||||
Path podMockResponsePath = moduleRoot.resolve(Paths.get("src/main/resources/mock-responses", toolName + ".json"));
|
||||
Files.createDirectories(wireMockBodyPath.getParent());
|
||||
Files.createDirectories(wireMockMappingPath.getParent());
|
||||
Files.createDirectories(podMockResponsePath.getParent());
|
||||
Files.writeString(wireMockBodyPath, mockResponse);
|
||||
Files.writeString(wireMockMappingPath, wireMockMappingContent(interfaceId, wireMockBodyPath.getFileName().toString()));
|
||||
Files.writeString(podMockResponsePath, mockResponse);
|
||||
ensureLocalHttpApiConfiguration(projectRoot, httpApiName, toolName);
|
||||
log.append("[WireMock Response] ").append(wireMockBodyPath).append("\n");
|
||||
log.append("[WireMock Mapping] ").append(wireMockMappingPath).append("\n");
|
||||
log.append("[Pod Mock Response] ").append(podMockResponsePath).append("\n");
|
||||
} else {
|
||||
Path mockResponsePath = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mock-responses", toolName + ".json"));
|
||||
Files.createDirectories(mockResponsePath.getParent());
|
||||
@@ -872,11 +901,184 @@ public class ToolScaffolder {
|
||||
Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName));
|
||||
log.append("[Unit Test] ").append(generatedTestPath).append("\\n");
|
||||
log.append("[Test Command] .\\gradlew.bat :").append(moduleName.substring(moduleName.lastIndexOf(java.io.File.separator) + 1)).append(":test --tests \"*").append(baseName).append("UseCaseTest\"\\n");
|
||||
log.append("\n Tip: HTTP Tool? WireMock???ㅽ뻾?????앹꽦??mapping URL濡??몄텧???뺤씤?섏꽭??\n");
|
||||
Files.createDirectories(definitionDir);
|
||||
Path definitionPath = definitionDir.resolve(toolName + ".yml");
|
||||
Files.writeString(definitionPath, toolDefinitionContentV17(toolName, title, description, group,
|
||||
interfaceId, inputFields, isMutationTool(baseName), definitionOptions));
|
||||
log.append("[V17 Tool Definition] ").append(definitionPath).append("\n");
|
||||
log.append("\n Tip: HTTP Tool은 WireMock 실행 후 생성된 mapping URL로 호출을 확인하세요.\n");
|
||||
|
||||
return log.toString();
|
||||
}
|
||||
|
||||
private static String toolDefinitionContent(String toolName, String title, String description,
|
||||
String categoryKey, String interfaceId,
|
||||
List<FieldDefinition> inputFields, boolean mutation) {
|
||||
String safeDescription = description == null || description.isBlank()
|
||||
? title + " 기능을 수행한다." : description;
|
||||
StringBuilder properties = new StringBuilder();
|
||||
StringBuilder required = new StringBuilder();
|
||||
Set<String> generatedNames = new LinkedHashSet<>();
|
||||
for (FieldDefinition field : inputFields == null ? List.<FieldDefinition>of() : inputFields) {
|
||||
if (field == null || field.name() == null || field.name().isBlank()
|
||||
|| !generatedNames.add(field.name().trim())) {
|
||||
continue;
|
||||
}
|
||||
properties.append(" ").append(field.name()).append(":\n")
|
||||
.append(" type: ").append(jsonSchemaType(field.type())).append("\n")
|
||||
.append(" description: ").append(yamlText(field.description())).append("\n");
|
||||
if (field.required()) {
|
||||
required.append(" - ").append(field.name()).append("\n");
|
||||
}
|
||||
}
|
||||
if (properties.isEmpty()) {
|
||||
properties.append(" {}\n");
|
||||
}
|
||||
String requiredBlock = required.isEmpty() ? "" : " required:\n" + required;
|
||||
String legacyLine = interfaceId == null || interfaceId.isBlank()
|
||||
? "" : "legacy_interface_id: " + yamlText(interfaceId) + "\n";
|
||||
return """
|
||||
name: %s
|
||||
display_name: %s
|
||||
version: 1.0.0
|
||||
category_key: %s
|
||||
description:
|
||||
function: %s
|
||||
when_to_use: 사용자가 이 업무 기능의 실행 또는 조회를 명확히 요청한 경우 사용한다.
|
||||
when_not_to_use: 입력값이 확인되지 않았거나 다른 업무 기능이 더 적합한 경우에는 사용하지 않는다.
|
||||
io_limits: 정의된 입력 항목만 허용하며 응답 DTO에 정의된 업무 결과만 반환한다.
|
||||
display_description: %s
|
||||
example_queries:
|
||||
- %s 처리해줘
|
||||
- %s 정보를 확인해줘
|
||||
- %s 업무 결과를 알려줘
|
||||
read_only: %s
|
||||
destructive: %s
|
||||
idempotent: %s
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
%s%s additionalProperties: false
|
||||
tags: [%s]
|
||||
%srequired_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
""".formatted(toolName, yamlText(title), categoryKey.toLowerCase(Locale.ROOT),
|
||||
yamlText(safeDescription), yamlText(title), yamlText(title), yamlText(title), yamlText(title),
|
||||
!mutation, mutation, !mutation, properties, requiredBlock,
|
||||
categoryKey.toLowerCase(Locale.ROOT), legacyLine);
|
||||
}
|
||||
|
||||
private static String toolDefinitionContentV17(String toolName, String title, String description,
|
||||
String categoryKey, String interfaceId,
|
||||
List<FieldDefinition> inputFields, boolean mutation,
|
||||
ToolDefinitionOptions options) {
|
||||
String function = option(options == null ? null : options.functionDescription(),
|
||||
option(description, title + " 기능을 수행한다."));
|
||||
String whenToUse = option(options == null ? null : options.whenToUse(),
|
||||
"사용자가 해당 업무 기능의 실행 또는 조회를 명확히 요청한 경우 사용한다.");
|
||||
String whenNotToUse = option(options == null ? null : options.whenNotToUse(),
|
||||
"필수 입력값이 확인되지 않았거나 다른 업무 기능이 더 적합한 경우에는 사용하지 않는다.");
|
||||
String ioLimits = option(options == null ? null : options.ioLimits(),
|
||||
"정의된 입력 항목만 허용하며 응답 DTO에 정의된 업무 결과만 반환한다.");
|
||||
String displayDescription = option(options == null ? null : options.displayDescription(), title);
|
||||
List<String> examples = normalizedList(options == null ? null : options.exampleQueries(), List.of(
|
||||
title + " 처리해줘", title + " 정보를 확인해줘", title + " 업무 결과를 알려줘"));
|
||||
List<String> tags = normalizedList(options == null ? null : options.tags(),
|
||||
List.of(categoryKey.toLowerCase(Locale.ROOT)));
|
||||
String ownerOrg = option(options == null ? null : options.ownerOrg(), "MCP_TOOL");
|
||||
|
||||
StringBuilder properties = new StringBuilder();
|
||||
StringBuilder required = new StringBuilder();
|
||||
Set<String> generatedNames = new LinkedHashSet<>();
|
||||
for (FieldDefinition field : inputFields == null ? List.<FieldDefinition>of() : inputFields) {
|
||||
if (field == null || field.name() == null || field.name().isBlank()
|
||||
|| !generatedNames.add(field.name().trim())) {
|
||||
continue;
|
||||
}
|
||||
properties.append(" ").append(field.name().trim()).append(":\n")
|
||||
.append(" type: ").append(jsonSchemaType(field.type())).append("\n")
|
||||
.append(" description: ").append(yamlText(field.description())).append("\n");
|
||||
if (field.required()) {
|
||||
required.append(" - ").append(field.name().trim()).append("\n");
|
||||
}
|
||||
}
|
||||
if (properties.isEmpty()) {
|
||||
properties.append(" {}\n");
|
||||
}
|
||||
String requiredBlock = required.isEmpty() ? "" : " required:\n" + required;
|
||||
String legacyLine = interfaceId == null || interfaceId.isBlank()
|
||||
? "" : "legacy_interface_id: " + yamlText(interfaceId) + "\n";
|
||||
String exampleBlock = examples.stream().map(value -> " - " + yamlText(value))
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
String tagBlock = tags.stream().map(ToolScaffolder::yamlText)
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
|
||||
return """
|
||||
name: %s
|
||||
display_name: %s
|
||||
version: 1.0.0
|
||||
category_key: %s
|
||||
description:
|
||||
function: %s
|
||||
when_to_use: %s
|
||||
when_not_to_use: %s
|
||||
io_limits: %s
|
||||
display_description: %s
|
||||
example_queries:
|
||||
%s
|
||||
read_only: %s
|
||||
destructive: %s
|
||||
idempotent: %s
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
%s%s additionalProperties: false
|
||||
tags: [%s]
|
||||
%srequired_env_keys: []
|
||||
owner_org: %s
|
||||
""".formatted(toolName, yamlText(title), categoryKey.toLowerCase(Locale.ROOT),
|
||||
yamlText(function), yamlText(whenToUse), yamlText(whenNotToUse), yamlText(ioLimits),
|
||||
yamlText(displayDescription), exampleBlock, !mutation, mutation, !mutation,
|
||||
properties, requiredBlock, tagBlock, legacyLine, yamlText(ownerOrg));
|
||||
}
|
||||
|
||||
private static String option(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value.trim();
|
||||
}
|
||||
|
||||
private static List<String> normalizedList(List<String> values, List<String> fallback) {
|
||||
if (values == null) {
|
||||
return fallback;
|
||||
}
|
||||
List<String> normalized = values.stream()
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(value -> !value.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
return normalized.isEmpty() ? fallback : normalized;
|
||||
}
|
||||
|
||||
private static boolean isMutationTool(String baseName) {
|
||||
String value = baseName.toLowerCase(Locale.ROOT);
|
||||
return value.matches(".*(create|add|update|delete|remove|send|process|approve|reject|register|issue).*");
|
||||
}
|
||||
|
||||
private static String jsonSchemaType(String javaType) {
|
||||
return switch (javaType == null ? "String" : javaType) {
|
||||
case "Integer", "Long" -> "integer";
|
||||
case "Double", "BigDecimal" -> "number";
|
||||
case "Boolean" -> "boolean";
|
||||
default -> "string";
|
||||
};
|
||||
}
|
||||
|
||||
private static String yamlText(String value) {
|
||||
String safe = value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
.replace("\r", " ").replace("\n", " ");
|
||||
return "\"" + safe + "\"";
|
||||
}
|
||||
|
||||
private static String toKebabCase(String pascalCase) {
|
||||
if (pascalCase == null || pascalCase.isEmpty()) return pascalCase;
|
||||
return pascalCase
|
||||
@@ -884,6 +1086,56 @@ public class ToolScaffolder {
|
||||
.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static void ensureLocalHttpApiConfiguration(Path projectRoot, String httpApiName, String toolName) throws IOException {
|
||||
Path localConfigPath = projectRoot.resolve("dap-was-lib/src/main/resources/glow/application-glow-local.yml");
|
||||
Files.createDirectories(localConfigPath.getParent());
|
||||
String existing = Files.exists(localConfigPath) ? Files.readString(localConfigPath) : "";
|
||||
if (java.util.regex.Pattern.compile("(?m)^\\s*-\\s+name:\\s*"
|
||||
+ java.util.regex.Pattern.quote(httpApiName) + "\\s*$").matcher(existing).find()) {
|
||||
return;
|
||||
}
|
||||
String environmentKey = toPackageSegment(httpApiName).toUpperCase(Locale.ROOT).replace('-', '_');
|
||||
String apiEntry = """
|
||||
- name: %s
|
||||
domain: ${AXHUB_%s_HTTP_DOMAIN:http://localhost:${server.port}}
|
||||
url: ${AXHUB_%s_HTTP_URL:/api/mock/http/%s}
|
||||
method: POST
|
||||
content-type: application/json;charset=UTF-8
|
||||
biz-pod: false
|
||||
""".formatted(httpApiName, environmentKey, environmentKey, toolName).stripTrailing() + "\n";
|
||||
if (existing.isBlank()) {
|
||||
existing = """
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: local
|
||||
|
||||
glow:
|
||||
communication:
|
||||
http:
|
||||
api-list:
|
||||
""" + apiEntry;
|
||||
} else if (existing.contains("\n mci:")) {
|
||||
existing = existing.replace("\n mci:", "\n" + apiEntry + " mci:");
|
||||
} else if (existing.contains("\naxhub:")) {
|
||||
existing = existing.replace("\naxhub:", apiEntry + "axhub:");
|
||||
} else if (existing.contains("api-list:")) {
|
||||
existing += apiEntry;
|
||||
} else {
|
||||
throw new IllegalStateException("application-glow-local.yml must define glow.communication.http.api-list");
|
||||
}
|
||||
if (!existing.contains("axhub:\n mock:\n http:\n enabled: true")) {
|
||||
existing += """
|
||||
|
||||
axhub:
|
||||
mock:
|
||||
http:
|
||||
enabled: true
|
||||
""";
|
||||
}
|
||||
Files.writeString(localConfigPath, existing);
|
||||
}
|
||||
|
||||
private static String dtoContent(String packageName, String className, List<FieldDefinition> fields,
|
||||
String author, String createDate, boolean request) {
|
||||
String body = fieldLines(fields, request ? Set.of() : Set.of("resultCode", "resultMessage"));
|
||||
@@ -999,21 +1251,24 @@ public class ToolScaffolder {
|
||||
|
||||
import %s.dto.%sRequest;
|
||||
import %s.dto.%sResponse;
|
||||
import %s.%s.io.%sHttpRequest;
|
||||
import %s.%s.io.%sHttpResponse;
|
||||
import %s.io.%sHttpRequest;
|
||||
import %s.io.%sHttpResponse;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface %sConverter {
|
||||
// Field names differ? Add mappings like this before the method.
|
||||
// @Mapping(source = "sourceField", target = "targetField")
|
||||
%sHttpRequest toHttpRequest(%sRequest request);
|
||||
%sResponse toResponse(%sHttpResponse httpResponse);
|
||||
}
|
||||
""".formatted(bizPackage,
|
||||
bizPackage, baseName,
|
||||
bizPackage, baseName,
|
||||
BASE_PACKAGE, httpPackage, baseName,
|
||||
BASE_PACKAGE, httpPackage, baseName,
|
||||
httpPackage, baseName,
|
||||
httpPackage, baseName,
|
||||
baseName, baseName, baseName, baseName, baseName);
|
||||
}
|
||||
|
||||
@@ -1021,7 +1276,7 @@ public class ToolScaffolder {
|
||||
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "_")
|
||||
.replaceAll("^_+|_+$", "");
|
||||
return normalized.isBlank() ? "sample" : normalized;
|
||||
return normalized.isBlank() ? "http_api" : normalized;
|
||||
}
|
||||
private static String mciConverterContent(String bizPackage, String baseName, String mciPackage, String interfaceId) {
|
||||
return """
|
||||
@@ -1032,9 +1287,13 @@ public class ToolScaffolder {
|
||||
import %s.%s.io.%s_I;
|
||||
import %s.%s.io.%s_O;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface %sConverter {
|
||||
// Field names differ? Add mappings like this before the method.
|
||||
// @Mapping(source = "sourceField", target = "targetField")
|
||||
%s_I toLegacyRequest(%sRequest request);
|
||||
%sRequest toRequest(%s_I mciRequest);
|
||||
%sResponse toResponse(%s_O mciRes);
|
||||
@@ -1053,10 +1312,13 @@ public class ToolScaffolder {
|
||||
import %s.legacy.%sLegacyRequest;
|
||||
import %s.legacy.%sLegacyResponse;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface %sConverter {
|
||||
// Field names differ? Add mappings like this before the method.
|
||||
// @Mapping(source = "sourceField", target = "targetField")
|
||||
%sLegacyRequest toLegacyRequest(%sRequest request);
|
||||
%sRequest toRequest(%sLegacyRequest legacyRequest);
|
||||
%sResponse toResponse(%sLegacyResponse legacyResponse);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.shinhanlife.dap.lib.validation;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** CI/CD에서 모든 Java MCP Tool과 V17 정의 파일의 1:1 대응을 검증합니다. */
|
||||
public final class ToolSchemaV17ValidationRunner {
|
||||
private static final Pattern MCP_TOOL_NAME = Pattern.compile(
|
||||
"@McpTool\\s*\\(\\s*name\\s*=\\s*\"([^\"]+)\"");
|
||||
|
||||
private ToolSchemaV17ValidationRunner() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length != 1) {
|
||||
throw new IllegalArgumentException("Usage: ToolSchemaV17ValidationRunner <project-root>");
|
||||
}
|
||||
validate(Path.of(args[0]));
|
||||
}
|
||||
|
||||
static void validate(Path projectRoot) {
|
||||
ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
|
||||
Map<String, Path> definitions = new LinkedHashMap<>();
|
||||
Set<String> toolNames = new LinkedHashSet<>();
|
||||
try (Stream<Path> files = Files.walk(projectRoot)) {
|
||||
for (Path file : files.filter(Files::isRegularFile).toList()) {
|
||||
String normalized = file.toString().replace('\\', '/');
|
||||
if (normalized.contains("/build/") || normalized.contains("/.gradle/")
|
||||
|| normalized.contains("/tmp_") || normalized.contains("/org/")) {
|
||||
continue;
|
||||
}
|
||||
if (normalized.endsWith(".java") && normalized.contains("/dap-was-")
|
||||
&& !normalized.contains("/dap-was-lib/")) {
|
||||
Matcher matcher = MCP_TOOL_NAME.matcher(Files.readString(file, StandardCharsets.UTF_8));
|
||||
while (matcher.find()) {
|
||||
toolNames.add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
if (normalized.contains("/src/main/resources/tool-definitions/")
|
||||
&& (normalized.endsWith(".yml") || normalized.endsWith(".yaml"))) {
|
||||
ToolDefinition definition = yamlMapper.readValue(file.toFile(), ToolDefinition.class);
|
||||
ToolDefinitionValidator.validate(definition, file.toString());
|
||||
Path previous = definitions.putIfAbsent(definition.name(), file);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Duplicate V17 Tool definition: " + definition.name()
|
||||
+ " [" + previous + ", " + file + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to scan Tool schema V17 files", exception);
|
||||
}
|
||||
Set<String> missing = new LinkedHashSet<>(toolNames);
|
||||
missing.removeAll(definitions.keySet());
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException("Missing V17 Tool definitions: " + missing);
|
||||
}
|
||||
Set<String> orphan = new LinkedHashSet<>(definitions.keySet());
|
||||
orphan.removeAll(toolNames);
|
||||
if (!orphan.isEmpty()) {
|
||||
throw new IllegalStateException("V17 definitions without matching @McpTool: " + orphan);
|
||||
}
|
||||
System.out.println("Tool schema V17 validation passed: " + toolNames.size() + " tools");
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,19 @@ public class ToolMetadata {
|
||||
private String displayName; // 사람이 읽는 라벨 (1-128자)
|
||||
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
|
||||
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
|
||||
private String functionDescription;
|
||||
private String whenToUse;
|
||||
private String whenNotToUse;
|
||||
private String ioLimits;
|
||||
private String displayDescription;
|
||||
private List<String> exampleQueries;
|
||||
private List<String> tags;
|
||||
private String ownerOrg;
|
||||
private List<String> requiredEnvKeys;
|
||||
|
||||
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
|
||||
private Map<String, Object> parametersSchema;
|
||||
private Map<String, Object> outputSchema;
|
||||
|
||||
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
|
||||
private Map<String, String> actionPrompts;
|
||||
|
||||
@@ -13,9 +13,15 @@ glow:
|
||||
connection-timeout: 5
|
||||
read-timeout: 5
|
||||
api-list:
|
||||
- name: sample
|
||||
domain: ${AXHUB_SAMPLE_HTTP_DOMAIN:http://localhost:8089}
|
||||
url: ${AXHUB_SAMPLE_HTTP_URL:/CLCNNB00001}
|
||||
- name: memo
|
||||
domain: ${AXHUB_MEMO_HTTP_DOMAIN:http://localhost:${server.port}}
|
||||
url: ${AXHUB_MEMO_HTTP_URL:/api/mock/http/cmm_memo_retriever}
|
||||
method: POST
|
||||
content-type: application/json;charset=UTF-8
|
||||
biz-pod: false
|
||||
- name: insurance
|
||||
domain: ${AXHUB_INSURANCE_HTTP_DOMAIN:http://localhost:${server.port}}
|
||||
url: ${AXHUB_INSURANCE_HTTP_URL:/api/mock/http/ins_insurance_processor}
|
||||
method: POST
|
||||
content-type: application/json;charset=UTF-8
|
||||
biz-pod: false
|
||||
@@ -28,3 +34,8 @@ glow:
|
||||
eai:
|
||||
host: ${GLOW_COMMUNICATION_EAI_HOST:http://localhost}
|
||||
port: ${GLOW_COMMUNICATION_EAI_PORT:8080}
|
||||
|
||||
axhub:
|
||||
mock:
|
||||
http:
|
||||
enabled: true
|
||||
|
||||
@@ -21,18 +21,8 @@ glow:
|
||||
http:
|
||||
connection-timeout: 5
|
||||
read-timeout: 5
|
||||
# HTTP Tool target catalog. Replace or add entries after the business endpoint is agreed.
|
||||
api-list:
|
||||
# WireMock/개발환경 샘플입니다. 컨테이너 내부에서는 localhost가 Tool Pod 자신을 뜻하므로
|
||||
# Docker 서비스명(mci-mock)과 컨테이너 포트(8080)를 기본값으로 사용합니다.
|
||||
# 로컬 PC에서 실행할 때는 AXHUB_SAMPLE_HTTP_DOMAIN=http://localhost:8089 로 덮어씁니다.
|
||||
- name: sample
|
||||
domain: ${AXHUB_SAMPLE_HTTP_DOMAIN:http://mci-mock:8080}
|
||||
# mci-mock/mappings/smp_employee_search.json의 urlPath와 동일해야 합니다.
|
||||
url: ${AXHUB_SAMPLE_HTTP_URL:/CLCNNB00001}
|
||||
method: POST
|
||||
content-type: application/json;charset=UTF-8
|
||||
biz-pod: false
|
||||
# HTTP Tool target catalog. Scaffold adds local mock entries after a Tool is created.
|
||||
api-list: []
|
||||
mci:
|
||||
uri: /ntl_mci/dap_rcv
|
||||
receive-uri: /itrf/mciReceive
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.shinhanlife.dap.lib.adapter.test;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MockEimsHttpServerTest {
|
||||
|
||||
@Test
|
||||
void returnsTheScaffoldGeneratedJsonResponse() {
|
||||
MockEimsHttpServer server = new MockEimsHttpServer(new ObjectMapper());
|
||||
|
||||
var response = server.mockToolHttpResponse("cmm_memo_retriever", null);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
assertThat(response.getBody().path("resultCode").asText()).isEqualTo("SUCCESS");
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,13 @@ import org.springframework.web.client.RestClient;
|
||||
class AxhubHttpComponentTest {
|
||||
|
||||
@Test
|
||||
void callResolvesDomainBuildsGlowTransferAndDeserializesJsonResponse() {
|
||||
void callByApiNameBuildsGlowTransferAndDeserializesJsonResponse() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
GlowHttpComponent glowHttpComponent = new GlowHttpComponent(builder);
|
||||
AxhubHttpProperties properties = new AxhubHttpProperties();
|
||||
properties.setApiList(List.of(new AxhubHttpProperties.ApiDefinition(
|
||||
"sample", "https://api.example.test", "/v1", HttpMethod.GET, "application/json", false)));
|
||||
"status", "https://api.example.test", "/v1", HttpMethod.GET, "application/json", false)));
|
||||
AxhubHttpComponent component = new AxhubHttpComponent(
|
||||
glowHttpComponent, new ObjectMapper(), new GlowCommunicationProperties(), properties);
|
||||
|
||||
@@ -33,7 +33,7 @@ class AxhubHttpComponentTest {
|
||||
.andExpect(header("X-ANONYMOUS-REQ", "AXHUB-TOOL"))
|
||||
.andRespond(withSuccess("{\"status\":\"OK\"}", APPLICATION_JSON));
|
||||
|
||||
SampleResponse response = component.call(AxhubHttpDomain.SAMPLE, "/status", null, SampleResponse.class);
|
||||
SampleResponse response = component.call("status", "/status", null, SampleResponse.class);
|
||||
|
||||
assertThat(response.status()).isEqualTo("OK");
|
||||
server.verify();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
|
||||
class ToolDefinitionRepositoryTest {
|
||||
|
||||
@Test
|
||||
void springCanCreateRepositoryWithoutDefaultConstructor() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(ToolDefinitionRepository.class);
|
||||
context.refresh();
|
||||
|
||||
assertTrue(context.getBean(ToolDefinitionRepository.class)
|
||||
.findByName("cmm_claim_search").isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsAndCachesAValidV17DefinitionByToolName() {
|
||||
ToolDefinitionRepository repository = new ToolDefinitionRepository(
|
||||
new ObjectMapper(new YAMLFactory()), new DefaultResourceLoader(),
|
||||
"classpath*:tool-definitions/**/*.yml");
|
||||
|
||||
ToolDefinition definition = repository.findByName("cmm_claim_search").orElseThrow();
|
||||
|
||||
assertEquals("보험금 청구 상태 조회", definition.displayName());
|
||||
assertEquals("cmm", definition.categoryKey());
|
||||
assertEquals(3, definition.exampleQueries().size());
|
||||
assertEquals(false, definition.parametersSchema().get("additionalProperties"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDefinitionWithFewerThanThreeExampleQueries() {
|
||||
ToolDefinition invalid = validDefinition().withExampleQueries(java.util.List.of("청구 상태 알려줘"));
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> ToolDefinitionValidator.validate(invalid, "memory:invalid"));
|
||||
|
||||
assertTrue(error.getMessage().contains("example_queries"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonStandardToolName() {
|
||||
ToolDefinition invalid = validDefinition().withName("cmm_claim.Search");
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> ToolDefinitionValidator.validate(invalid, "memory:invalid"));
|
||||
|
||||
assertTrue(error.getMessage().contains("name"));
|
||||
}
|
||||
|
||||
private ToolDefinition validDefinition() {
|
||||
return new ToolDefinition(
|
||||
"cmm_claim_search", "보험금 청구 상태 조회", "1.0.0", "cmm",
|
||||
new ToolDescription("청구 상태를 조회한다.", "상태 확인 시 사용한다.",
|
||||
"청구 접수 시 사용하지 않는다.", "청구번호가 필요하다."),
|
||||
"보험금 청구 상태를 조회합니다.",
|
||||
java.util.List.of("청구 상태 알려줘", "심사 결과 조회해줘", "계약번호로 청구를 찾아줘"),
|
||||
true, false, true,
|
||||
java.util.Map.of("type", "object", "properties", java.util.Map.of(),
|
||||
"additionalProperties", false),
|
||||
null,
|
||||
java.util.List.of("보험금"), null, java.util.List.of(), "MCP_TOOL");
|
||||
}
|
||||
}
|
||||
@@ -91,18 +91,25 @@ class JsonSchemaGeneratorTest {
|
||||
|
||||
private static class ValidatedRequest {
|
||||
@McpToolParam(description = "recipient phone number", required = true)
|
||||
@io.swagger.v3.oas.annotations.media.Schema(pattern = "^01[0-9]{8,9}$")
|
||||
private String phoneNumber;
|
||||
|
||||
@McpToolParam(description = "issue amount", required = true)
|
||||
@io.swagger.v3.oas.annotations.media.Schema(minimum = "1")
|
||||
private Long amount;
|
||||
|
||||
@McpToolParam(description = "approval result")
|
||||
@io.swagger.v3.oas.annotations.media.Schema(
|
||||
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"APPROVE", "REJECT"})
|
||||
private String approvalStatus;
|
||||
|
||||
@McpToolParam(description = "page size")
|
||||
@io.swagger.v3.oas.annotations.media.Schema(maximum = "50", defaultValue = "20")
|
||||
private Integer pageSize;
|
||||
|
||||
@McpToolParam(description = "reference")
|
||||
@io.swagger.v3.oas.annotations.media.Schema(minLength = 1, maxLength = 30)
|
||||
private String reference;
|
||||
}
|
||||
|
||||
@@ -115,6 +122,9 @@ class JsonSchemaGeneratorTest {
|
||||
}
|
||||
|
||||
private static class NestedChild {
|
||||
@io.swagger.v3.oas.annotations.media.Schema(
|
||||
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
|
||||
pattern = "^\\d{8}$")
|
||||
private String businessDate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package io.shinhanlife.dap.lib.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
@@ -11,6 +16,53 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ToolScaffolderTest {
|
||||
|
||||
@Test
|
||||
void generatesV17ToolDefinitionTogetherWithToolSources() throws Exception {
|
||||
String moduleName = root.resolve("dap-was-v17-definition").toString();
|
||||
|
||||
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회",
|
||||
"사번으로 재직 중인 직원을 조회한다.", "smp", "HTTP", moduleName,
|
||||
"tester", "2026.08.12", false, null, null, null,
|
||||
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "09860000", true)),
|
||||
List.of(), "employee");
|
||||
|
||||
Path definition = root.resolve("dap-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml");
|
||||
String yaml = Files.readString(definition);
|
||||
|
||||
assertTrue(yaml.contains("name: smp_employee_search"), yaml);
|
||||
assertTrue(yaml.contains("when_to_use:"), yaml);
|
||||
assertTrue(yaml.contains("example_queries:"), yaml);
|
||||
assertTrue(yaml.contains("additionalProperties: false"), yaml);
|
||||
assertTrue(yaml.contains("owner_org: \"MCP_TOOL\""), yaml);
|
||||
ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(yaml, ToolDefinition.class);
|
||||
ToolDefinitionValidator.validate(parsed, definition.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesV17MetadataEnteredByScaffoldUser() throws Exception {
|
||||
String moduleName = root.resolve("dap-was-v17-options").toString();
|
||||
ToolScaffolder.ToolDefinitionOptions options = new ToolScaffolder.ToolDefinitionOptions(
|
||||
"사번으로 직원을 조회한다.",
|
||||
"직원 정보 조회 요청에 사용한다.",
|
||||
"사번이 없으면 사용하지 않는다.",
|
||||
"최대 1건만 반환한다.",
|
||||
"직원 기본 정보 조회",
|
||||
List.of("사번 10001을 조회해줘", "직원 10001 소속을 알려줘", "10001 직원을 찾아줘"),
|
||||
List.of("employee", "search"), "HR_TEAM");
|
||||
|
||||
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", "직원을 조회한다.",
|
||||
"smp", "HTTP", moduleName, "tester", "2026.08.12", false, null, null, null,
|
||||
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "10001", true)),
|
||||
List.of(), "employee", options);
|
||||
|
||||
Path definition = root.resolve("dap-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml");
|
||||
ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(Files.readString(definition), ToolDefinition.class);
|
||||
assertEquals("HR_TEAM", parsed.ownerOrg());
|
||||
assertEquals("10001 직원을 찾아줘", parsed.exampleQueries().get(2));
|
||||
assertEquals(2, parsed.tags().size());
|
||||
ToolDefinitionValidator.validate(parsed, definition.toString());
|
||||
}
|
||||
|
||||
@TempDir
|
||||
Path root;
|
||||
|
||||
@@ -149,7 +201,7 @@ class ToolScaffolderTest {
|
||||
|
||||
List<ToolScaffolder.FieldDefinition> inputFields = List.of(
|
||||
new ToolScaffolder.FieldDefinition("employeeId", "String", "Employee identifier", "EMP10001", true));
|
||||
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "Employee search", "smp", "HTTP", moduleName,
|
||||
String resultLog = ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "Employee search", "smp", "HTTP", moduleName,
|
||||
"tester", "2026.08.11", false, null, null, null, inputFields, outputFields);
|
||||
|
||||
Path dtoRoot = root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/dto");
|
||||
@@ -161,20 +213,22 @@ class ToolScaffolderTest {
|
||||
assertTrue(response.indexOf("private String resultCode;") == response.lastIndexOf("private String resultCode;"), response);
|
||||
assertTrue(response.indexOf("private String employeeName;") == response.lastIndexOf("private String employeeName;"), response);
|
||||
String converter = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/converter/EmployeeSearchConverter.java"));
|
||||
String httpRequest = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/http/sample/io/EmployeeSearchHttpRequest.java"));
|
||||
String httpResponse = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/http/sample/io/EmployeeSearchHttpResponse.java"));
|
||||
String httpClient = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/http/sample/SampleClient.java"));
|
||||
String httpRequest = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/http/employee_search/io/EmployeeSearchHttpRequest.java"));
|
||||
String httpResponse = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/http/employee_search/io/EmployeeSearchHttpResponse.java"));
|
||||
String httpClient = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/http/employee_search/EmployeeSearchClient.java"));
|
||||
assertFalse(converter.contains("phoneNumber"), converter);
|
||||
assertTrue(converter.contains("infra.itrf.http.sample.io.EmployeeSearchHttpRequest"), converter);
|
||||
assertTrue(converter.contains("infra.itrf.http.employee_search.io.EmployeeSearchHttpRequest"), converter);
|
||||
assertFalse(converter.contains("io.shinhanlife.dap.mcc.io.shinhanlife.dap.mcc"), converter);
|
||||
assertTrue(converter.contains("// @Mapping(source = \"sourceField\", target = \"targetField\")"), converter);
|
||||
assertTrue(httpRequest.contains("private String employeeId;"), httpRequest);
|
||||
assertTrue(httpResponse.contains("private String employeeName;"), httpResponse);
|
||||
assertTrue(httpClient.contains("http.call(API_NAME, request, responseType)"), httpClient);
|
||||
assertFalse(Files.exists(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/legacy")));
|
||||
String implementation = Files.readString(root.resolve("dap-was-http/src/main/java/io/shinhanlife/dap/mcc/biz/smp/usecase/impl/EmployeeSearchUseCaseImpl.java"));
|
||||
assertTrue(implementation.contains("public EmployeeSearchResponse execute(EmployeeSearchRequest req)"), implementation);
|
||||
assertTrue(implementation.contains("import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleClient;"), implementation);
|
||||
assertTrue(implementation.contains("private final SampleClient sampleClient;"), implementation);
|
||||
assertTrue(implementation.contains("sampleClient.call(httpRequest, EmployeeSearchHttpResponse.class)"), implementation);
|
||||
assertTrue(implementation.contains("import io.shinhanlife.dap.mcc.infra.itrf.http.employee_search.EmployeeSearchClient;"), implementation);
|
||||
assertTrue(implementation.contains("private final EmployeeSearchClient employeeSearchClient;"), implementation);
|
||||
assertTrue(implementation.contains("employeeSearchClient.call(httpRequest, EmployeeSearchHttpResponse.class)"), implementation);
|
||||
assertFalse(implementation.contains("AxhubHttpComponent"), implementation);
|
||||
assertFalse(implementation.contains("executeLegacy(\"HTTP\""), implementation);
|
||||
Path wireMockResponse = root.resolve("mci-mock/__files/smp_employee_search.json");
|
||||
@@ -182,6 +236,20 @@ class ToolScaffolderTest {
|
||||
assertTrue(Files.exists(wireMockResponse), wireMockResponse.toString());
|
||||
assertTrue(Files.exists(wireMockMapping), wireMockMapping.toString());
|
||||
assertTrue(Files.readString(wireMockMapping).contains("\"urlPath\" : \"/HR_EMPLOYEE_SEARCH\""));
|
||||
Path localConfig = root.resolve("dap-was-lib/src/main/resources/glow/application-glow-local.yml");
|
||||
assertTrue(Files.exists(localConfig), localConfig.toString());
|
||||
assertTrue(Files.readString(localConfig).contains("name: employee-search"));
|
||||
assertTrue(Files.readString(localConfig).contains("url: ${AXHUB_EMPLOYEE_SEARCH_HTTP_URL:/api/mock/http/smp_employee_search}"));
|
||||
Path podMockResponse = root.resolve("dap-was-http/src/main/resources/mock-responses/smp_employee_search.json");
|
||||
assertTrue(Files.exists(podMockResponse), podMockResponse.toString());
|
||||
assertTrue(resultLog.contains("Tip: HTTP Tool은 WireMock 실행 후 생성된 mapping URL로 호출을 확인하세요."), resultLog);
|
||||
|
||||
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "Employee search", "smp", "HTTP", moduleName,
|
||||
"tester", "2026.08.11", false, null, null, null, inputFields, outputFields);
|
||||
long apiNameCount = Files.readAllLines(localConfig).stream()
|
||||
.filter(line -> line.trim().equals("- name: employee-search"))
|
||||
.count();
|
||||
assertEquals(1, apiNameCount);
|
||||
}
|
||||
@Test
|
||||
void generatesSeparateToolTitleAndDescription() throws Exception {
|
||||
@@ -213,4 +281,29 @@ class ToolScaffolderTest {
|
||||
assertFalse(implementation.contains("AxhubHttpDomain"), implementation);
|
||||
assertFalse(implementation.contains("\"/HR_EMPLOYEE_SEARCH\""), implementation);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsHttpApiEntryOnItsOwnYamlLineBeforeMciConfiguration() throws Exception {
|
||||
String moduleName = root.resolve("dap-was-http-yaml").toString();
|
||||
Path localConfig = root.resolve("dap-was-lib/src/main/resources/glow/application-glow-local.yml");
|
||||
Files.createDirectories(localConfig.getParent());
|
||||
Files.writeString(localConfig, """
|
||||
glow:
|
||||
communication:
|
||||
http:
|
||||
api-list:
|
||||
- name: memo
|
||||
biz-pod: false
|
||||
mci:
|
||||
host: localhost
|
||||
""");
|
||||
|
||||
ToolScaffolder.scaffold("insurance claim processor", "CLAIM0000001", "Insurance claim", "Insurance claim", "ins", "HTTP", moduleName,
|
||||
"tester", "2026.08.11", false, null, null, null, List.of(), List.of(), "insurance");
|
||||
|
||||
String yaml = Files.readString(localConfig);
|
||||
assertFalse(yaml.contains("biz-pod: false - name"), yaml);
|
||||
assertTrue(yaml.contains(" biz-pod: false\n mci:"), yaml);
|
||||
assertTrue(yaml.contains(" - name: insurance"), yaml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ class ToolManifestServiceTest {
|
||||
assertTrue(item.annotations().readOnlyHint());
|
||||
assertEquals("1.2.0", item.meta().version());
|
||||
assertEquals(3000, item.meta().timeoutMillis());
|
||||
assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"),
|
||||
item.meta().exampleQueries());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,6 +88,9 @@ class ToolManifestServiceTest {
|
||||
.podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080")
|
||||
.displayName("계약 조회")
|
||||
.description("계약번호로 계약 정보를 조회합니다.")
|
||||
.exampleQueries(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"))
|
||||
.tags(List.of("계약"))
|
||||
.ownerOrg("MCP_TOOL")
|
||||
.parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")),
|
||||
"required", List.of("contractNo"), "additionalProperties", false))
|
||||
.semver(version)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"resultCode": "SUCCESS"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: cmm_claim_search
|
||||
display_name: 보험금 청구 상태 조회
|
||||
version: 1.0.0
|
||||
category_key: cmm
|
||||
description:
|
||||
function: 청구번호 또는 계약번호로 보험금 청구 상태를 조회한다.
|
||||
when_to_use: 사용자가 보험금 청구 진행 상태나 심사 결과를 확인하려는 경우 사용한다.
|
||||
when_not_to_use: 보험금 청구를 새로 접수하거나 기존 청구를 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 청구번호 또는 계약번호 중 하나 이상이 필요하며 조회 결과만 반환한다.
|
||||
display_description: 보험금 청구 상태와 심사 결과를 조회합니다.
|
||||
example_queries:
|
||||
- 내 보험금 청구가 어디까지 진행됐는지 알려줘
|
||||
- 계약번호로 최근 청구 상태를 확인해줘
|
||||
- 청구 심사 결과가 나왔는지 조회해줘
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
claimNo:
|
||||
type: string
|
||||
description: 조회할 보험금 청구번호
|
||||
required:
|
||||
- claimNo
|
||||
additionalProperties: false
|
||||
tags:
|
||||
- 보험금
|
||||
- 청구조회
|
||||
owner_org: MCP_TOOL
|
||||
@@ -1,58 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||
|
||||
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springaicommunity.mcp.annotation.McpToolParam;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 보험금 청구 조회 Tool의 입력 DTO 샘플이다.
|
||||
* 청구번호 또는 계약번호 중 하나를 반드시 입력받는다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ClaimSearchRequest {
|
||||
|
||||
@McpToolParam(description = "청구번호. CLM 다음 숫자 13자리 형식이다.")
|
||||
@Schema(pattern = "^CLM[0-9]{13}$", example = "CLM2026070100123")
|
||||
private String claimNo;
|
||||
|
||||
@McpToolParam(description = "계약번호. 숫자 11자리 형식이다.")
|
||||
@Schema(pattern = "^[0-9]{11}$", example = "10023456789")
|
||||
private String contractNo;
|
||||
|
||||
@McpToolParam(description = "청구 상태 필터")
|
||||
@Schema(example = "RECEIVED", allowableValues = {
|
||||
"RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED",
|
||||
"APPROVED", "PAID", "REJECTED", "WITHDRAWN"
|
||||
})
|
||||
private String status;
|
||||
|
||||
@McpToolParam(description = "청구 유형 필터")
|
||||
@Schema(example = "MEDICAL", allowableValues = {
|
||||
"MEDICAL", "SURGERY", "HOSPITALIZATION",
|
||||
"DIAGNOSIS", "DEATH", "DISABILITY"
|
||||
})
|
||||
private String claimType;
|
||||
|
||||
@McpToolParam(description = "접수일 조회 시작일(YYYY-MM-DD)")
|
||||
@Schema(format = "date", example = "2026-01-01")
|
||||
private String fromDate;
|
||||
|
||||
@McpToolParam(description = "접수일 조회 종료일(YYYY-MM-DD)")
|
||||
@Schema(format = "date", example = "2026-07-31")
|
||||
private String toDate;
|
||||
|
||||
@McpToolParam(description = "반환할 최대 건수 (기본값: 20, 최대: 50)")
|
||||
@Schema(pattern = "^[1-9][0-9]?$|^50$", defaultValue = "20", example = "20")
|
||||
private String size;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||
|
||||
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springaicommunity.mcp.annotation.McpToolParam;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Claim search response sample.
|
||||
* Complex response rules are defined by outputSchemaResource; annotations document
|
||||
* the same simple field constraints for automatic schema generation examples.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ClaimSearchResponse {
|
||||
|
||||
@McpToolParam(description = "Execution result code.")
|
||||
@Schema(required = true, allowableValues = {"SUCCESS", "FAILURE"})
|
||||
private String resultCode;
|
||||
|
||||
@McpToolParam(description = "User-readable label for resultCode.")
|
||||
@Schema(required = true, maxLength = 100)
|
||||
private String resultLabel;
|
||||
|
||||
@McpToolParam(description = "Current claim processing status code.")
|
||||
@Schema(required = true, allowableValues = {
|
||||
"RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED",
|
||||
"APPROVED", "PAID", "REJECTED", "WITHDRAWN"
|
||||
})
|
||||
private String status;
|
||||
|
||||
@McpToolParam(description = "User-readable label for status.")
|
||||
@Schema(required = true, maxLength = 100)
|
||||
private String statusLabel;
|
||||
|
||||
@McpToolParam(description = "Approved amount. Null before review; do not interpret null as zero.")
|
||||
@Schema(minimum = "0", nullable = true)
|
||||
private Long approvedAmount;
|
||||
|
||||
@McpToolParam(description = "Present only when status is REJECTED; otherwise null.")
|
||||
@Schema(maxLength = 200, nullable = true)
|
||||
private String rejectionReason;
|
||||
|
||||
@McpToolParam(description = "Claim summaries, ordered by received date descending.")
|
||||
@Schema(required = true)
|
||||
private List<ClaimSummary> items;
|
||||
|
||||
@McpToolParam(description = "True when additional results exist beyond this response.")
|
||||
@Schema(required = true)
|
||||
private Boolean hasMore;
|
||||
|
||||
@McpToolParam(description = "Total number of matched claims.")
|
||||
@Schema(required = true, nullable = true)
|
||||
private Integer totalCount;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ClaimSummary {
|
||||
|
||||
@McpToolParam(description = "Claim processing status code.")
|
||||
private String status;
|
||||
|
||||
@McpToolParam(description = "User-readable label for status.")
|
||||
private String statusLabel;
|
||||
|
||||
@McpToolParam(description = "Received date in YYYY-MM-DD format.")
|
||||
@Schema(required = true, format = "date")
|
||||
private String receivedDate;
|
||||
|
||||
@McpToolParam(description = "Approved amount. Null before review; do not interpret null as zero.")
|
||||
@Schema(nullable = true)
|
||||
private Long approvedAmount;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchResponse;
|
||||
|
||||
public interface ClaimSearchSchemaSampleUseCase {
|
||||
|
||||
@McpTool(name = "cmm_claim_schema_search", title = "청구 조회 스키마 샘플", description = "Claim search Tool sample using input and output JSON Schema resources.", annotations = @McpTool.McpAnnotations(readOnlyHint = true, idempotentHint = true))
|
||||
@ToolHint(register = false, categoryKey = "cmm",
|
||||
inputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-input-schema.json",
|
||||
outputSchemaResource = "classpath:tool-schemas/cmm/claim-search-resource-output-schema.json")
|
||||
ClaimSearchResponse search(ClaimSearchRequest request);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
|
||||
* </pre>
|
||||
*/
|
||||
public interface MetaCommonCodeUseCase {
|
||||
@McpTool(name = "cmm_commonCode_lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true))
|
||||
@McpTool(name = "cmm_comcode_lookup", title = "메타 공통코드 조회 툴", description = "메타 통합코드 목록을 조회해줘", annotations = @McpTool.McpAnnotations(openWorldHint = true))
|
||||
@ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
|
||||
Object execute(MetaCommonCodeRequest req);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.usecase.impl;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.ClaimSearchResponse;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Non-exposed sample Tool. It does not call MCI or EIMS.
|
||||
*/
|
||||
@Service
|
||||
public class ClaimSearchSchemaSampleUseCaseImpl implements ClaimSearchSchemaSampleUseCase {
|
||||
|
||||
@Override
|
||||
public ClaimSearchResponse search(ClaimSearchRequest request) {
|
||||
ClaimSearchResponse.ClaimSummary item = ClaimSearchResponse.ClaimSummary.builder()
|
||||
.status("REVIEWING")
|
||||
.statusLabel("Under review")
|
||||
.receivedDate("2026-08-04")
|
||||
.approvedAmount(null)
|
||||
.build();
|
||||
|
||||
return ClaimSearchResponse.builder()
|
||||
.resultCode("SUCCESS")
|
||||
.resultLabel("Success")
|
||||
.status("REVIEWING")
|
||||
.statusLabel("Under review")
|
||||
.approvedAmount(null)
|
||||
.rejectionReason(null)
|
||||
.items(List.of(item))
|
||||
.hasMore(false)
|
||||
.totalCount(1)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.converter;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.insurance.io.InsuranceClaimProcessorHttpRequest;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.insurance.io.InsuranceClaimProcessorHttpResponse;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface InsuranceClaimProcessorConverter {
|
||||
// Field names differ? Add mappings like this before the method.
|
||||
// @Mapping(source = "sourceField", target = "targetField")
|
||||
InsuranceClaimProcessorHttpRequest toHttpRequest(InsuranceClaimProcessorRequest request);
|
||||
InsuranceClaimProcessorResponse toResponse(InsuranceClaimProcessorHttpResponse httpResponse);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class InsuranceClaimProcessorRequest {
|
||||
@Schema(description = "보험 청구 번호", example = "CLM20230001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String claimNumber;
|
||||
|
||||
@Schema(description = "청구 금액", example = "1500000.00", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Double claimAmount;
|
||||
|
||||
@Schema(description = "청구 일자 (YYYY-MM-DD)", example = "2023-09-15", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String claimDate;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class InsuranceClaimProcessorResponse {
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
@Schema(description = "청구 처리 고유 식별자", example = "CLM20230001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String claimId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcc.biz.ins.usecase
|
||||
* @className InsuranceClaimProcessorUseCase
|
||||
* @description AX HUB ?쒖뒪??泥섎━ ?대옒??
|
||||
* @author Admin
|
||||
* @create 2026.08.11
|
||||
* <pre>
|
||||
* ---------- 媛쒖젙?대젰 ----------
|
||||
* ?섏젙?? ?섏젙?? ?섏젙?댁슜
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.08.11 Admin 理쒖큹?앹꽦
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface InsuranceClaimProcessorUseCase {
|
||||
|
||||
@McpTool(name = "ins_insurance_processor", title = "보험금 청구", description = "보험금 청구 요청을 처리하고 결과를 반환하는 LLM 도구 가이드")
|
||||
@ToolHint(register = false, categoryKey = "ins", mappingId = "CLAIM0000001")
|
||||
InsuranceClaimProcessorResponse execute(InsuranceClaimProcessorRequest req);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.usecase.impl;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.ins.converter.InsuranceClaimProcessorConverter;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.insurance.InsuranceClient;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.insurance.io.InsuranceClaimProcessorHttpRequest;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.insurance.io.InsuranceClaimProcessorHttpResponse;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.usecase.InsuranceClaimProcessorUseCase;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InsuranceClaimProcessorUseCaseImpl implements InsuranceClaimProcessorUseCase {
|
||||
|
||||
private final InsuranceClaimProcessorConverter converter;
|
||||
private final InsuranceClient insuranceClient;
|
||||
|
||||
@Override
|
||||
public InsuranceClaimProcessorResponse execute(InsuranceClaimProcessorRequest req) {
|
||||
InsuranceClaimProcessorHttpRequest httpRequest = converter.toHttpRequest(req);
|
||||
InsuranceClaimProcessorHttpResponse httpResponse = insuranceClient.call(httpRequest, InsuranceClaimProcessorHttpResponse.class);
|
||||
|
||||
InsuranceClaimProcessorResponse response = converter.toResponse(httpResponse);
|
||||
response.setResultCode("SUCCESS");
|
||||
response.setResultMessage("HTTP API call completed.");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.oth.dto.*;
|
||||
|
||||
public interface Onnba3011UseCase {
|
||||
@McpTool(name = "onnba3011_call", description = "Onnba3011 호출 툴")
|
||||
@McpTool(name = "oth_onnba3011_call", description = "Onnba3011 호출 툴")
|
||||
@ToolHint(categoryKey = "oth", register = false)
|
||||
Object execute(Onnba3011Request req);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/** Tool response for the HTTP integration sample. */
|
||||
public record SampleHttpStatusResponse(
|
||||
@Schema(description = "External API processing status", example = "OK") String status,
|
||||
@Schema(description = "External API response message", example = "Sample API is available") String message) {
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
|
||||
|
||||
public interface ExchangeRateToolUseCase {
|
||||
@McpTool(name = "smp_exchangeRate_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)")
|
||||
@McpTool(name = "smp_exchange_inquiry", title = "실시간 환율 조회 툴", description = "원하는 통화의 실시간 환율을 조회합니다. (예: USD, EUR, JPY)")
|
||||
@ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001")
|
||||
ExchangeRateResponse execute(ExchangeRateRequest req);
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.usecase;
|
||||
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.SampleHttpStatusResponse;
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
|
||||
/** Sample Tool that demonstrates a configured HTTP API integration. */
|
||||
public interface SampleHttpStatusUseCase {
|
||||
|
||||
@McpTool(name = "smp_sample_status",
|
||||
title = "Sample external HTTP API status",
|
||||
description = "Calls the configured sample HTTP API and returns its status.")
|
||||
@ToolHint(register = false, categoryKey = "smp", mappingId = "HTTP_SAMPLE_001")
|
||||
SampleHttpStatusResponse execute();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.usecase.impl;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.SampleHttpStatusResponse;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.usecase.SampleHttpStatusUseCase;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleHttpApiClient;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleHttpApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SampleHttpStatusUseCaseImpl implements SampleHttpStatusUseCase {
|
||||
|
||||
private final SampleHttpApiClient sampleHttpApiClient;
|
||||
|
||||
@Override
|
||||
public SampleHttpStatusResponse execute() {
|
||||
SampleHttpApiResponse response = sampleHttpApiClient.getStatus();
|
||||
return new SampleHttpStatusResponse(response.status(), response.message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.insurance;
|
||||
|
||||
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class InsuranceClient {
|
||||
private static final String API_NAME = "insurance";
|
||||
|
||||
private final AxhubHttpComponent http;
|
||||
|
||||
public <I, O> O call(I request, Class<O> responseType) {
|
||||
return http.call(API_NAME, request, responseType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.insurance.io;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class InsuranceClaimProcessorHttpRequest {
|
||||
@Schema(description = "보험 청구 번호", example = "CLM20230001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String claimNumber;
|
||||
|
||||
@Schema(description = "청구 금액", example = "1500000.00", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Double claimAmount;
|
||||
|
||||
@Schema(description = "청구 일자 (YYYY-MM-DD)", example = "2023-09-15", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String claimDate;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.insurance.io;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class InsuranceClaimProcessorHttpResponse {
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
@Schema(description = "청구 처리 고유 식별자", example = "CLM20230001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String claimId;
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.sample;
|
||||
|
||||
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;
|
||||
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpDomain;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Example of a Tool-specific HTTP client using the configured AX HUB HTTP domain. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SampleHttpApiClient {
|
||||
|
||||
private final AxhubHttpComponent http;
|
||||
|
||||
public SampleHttpApiResponse getStatus() {
|
||||
return http.call(AxhubHttpDomain.SAMPLE, "/status", null, SampleHttpApiResponse.class);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.sample;
|
||||
|
||||
/** DTO matching the external API JSON response: { "status": "OK", "message": "..." }. */
|
||||
public record SampleHttpApiResponse(String status, String message) {
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"claimNo": {
|
||||
"type": "string",
|
||||
"description": "청구번호. CLM 이후 숫자 13자리 형식입니다.",
|
||||
"pattern": "^CLM[0-9]{13}$",
|
||||
"example": "CLM2026070100123"
|
||||
},
|
||||
"contractNo": {
|
||||
"type": "string",
|
||||
"description": "계약번호. 숫자 11자리 형식입니다.",
|
||||
"pattern": "^[0-9]{11}$",
|
||||
"example": "10023456789"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "청구 상태 필터",
|
||||
"enum": ["RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED", "APPROVED", "PAID", "REJECTED", "WITHDRAWN"],
|
||||
"example": "RECEIVED"
|
||||
},
|
||||
"claimType": {
|
||||
"type": "string",
|
||||
"description": "청구 유형 필터",
|
||||
"enum": ["MEDICAL", "SURGERY", "HOSPITALIZATION", "DIAGNOSIS", "DEATH", "DISABILITY"],
|
||||
"example": "MEDICAL"
|
||||
},
|
||||
"fromDate": {
|
||||
"type": "string",
|
||||
"description": "접수일 조회 시작일 (YYYY-MM-DD)",
|
||||
"format": "date",
|
||||
"example": "2026-01-01"
|
||||
},
|
||||
"toDate": {
|
||||
"type": "string",
|
||||
"description": "접수일 조회 종료일 (YYYY-MM-DD)",
|
||||
"format": "date",
|
||||
"example": "2026-07-31"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"description": "반환할 최대 건수 (기본값: 20, 최대: 50)",
|
||||
"pattern": "^[1-9][0-9]?$|^50$",
|
||||
"default": "20",
|
||||
"example": "20"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"resultCode": {
|
||||
"type": "string",
|
||||
"description": "실행 결과 코드",
|
||||
"enum": ["SUCCESS", "FAILURE"]
|
||||
},
|
||||
"resultLabel": {
|
||||
"type": "string",
|
||||
"description": "resultCode의 사용자 표시 라벨",
|
||||
"maxLength": 100
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "현재 청구 처리 상태 코드",
|
||||
"enum": ["RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED", "APPROVED", "PAID", "REJECTED", "WITHDRAWN"]
|
||||
},
|
||||
"statusLabel": {
|
||||
"type": "string",
|
||||
"description": "status의 사용자 표시 라벨",
|
||||
"maxLength": 100
|
||||
},
|
||||
"approvedAmount": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "승인 금액. 심사 전에는 null이며 0으로 해석하지 않습니다."
|
||||
},
|
||||
"rejectionReason": {
|
||||
"anyOf": [
|
||||
{"type": "string", "maxLength": 200},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "status가 REJECTED일 때만 존재하며, 그 외에는 null입니다."
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "수령일 내림차순으로 정렬된 청구 요약 목록",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "청구 처리 상태 코드"
|
||||
},
|
||||
"statusLabel": {
|
||||
"type": "string",
|
||||
"description": "status의 사용자 표시 라벨"
|
||||
},
|
||||
"receivedDate": {
|
||||
"type": "string",
|
||||
"description": "접수일 (YYYY-MM-DD 형식)",
|
||||
"format": "date"
|
||||
},
|
||||
"approvedAmount": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "승인 금액. 심사 전에는 null"
|
||||
}
|
||||
},
|
||||
"required": ["receivedDate"]
|
||||
}
|
||||
},
|
||||
"hasMore": {
|
||||
"type": "boolean",
|
||||
"description": "이 응답 이후 추가 결과가 있는지 여부"
|
||||
},
|
||||
"totalCount": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "매칭된 청구 건수 합계"
|
||||
}
|
||||
},
|
||||
"required": ["resultCode", "resultLabel", "status", "statusLabel", "items", "hasMore"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"resultCode" : "SUCCESS",
|
||||
"claimId" : "CLM20230001"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
name: cmm_comcode_lookup
|
||||
display_name: 메타 공통코드 조회
|
||||
version: 1.0.0
|
||||
category_key: cmm
|
||||
description:
|
||||
function: 통합코드 그룹과 코드명 조건으로 메타 공통코드 목록을 조회한다.
|
||||
when_to_use: 업무 코드의 값과 표시명을 확인하거나 유효한 코드 목록이 필요한 경우 사용한다.
|
||||
when_not_to_use: 공통코드를 신규 등록하거나 변경 또는 삭제하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 검색 조건에 맞는 코드와 코드명만 반환하며 코드 데이터는 변경하지 않는다.
|
||||
display_description: 메타 시스템의 공통코드 목록을 조회합니다.
|
||||
example_queries: ["사용 상태 코드 목록을 알려줘", "고객 구분 공통코드를 찾아줘", "사용 중인 통합코드를 조회해줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
groupCode: {type: string, description: 조회할 통합코드 그룹 ID}
|
||||
codeName: {type: string, description: 코드명 검색 키워드}
|
||||
useYn: {type: string, description: 사용 여부 Y 또는 N, enum: [Y, N]}
|
||||
additionalProperties: false
|
||||
tags: [메타, 공통코드]
|
||||
legacy_interface_id: CLCNNB00001
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,24 @@
|
||||
name: cmm_meta_table
|
||||
display_name: 메타 테이블 조회
|
||||
version: 1.0.0
|
||||
category_key: cmm
|
||||
description:
|
||||
function: 물리명, 논리명 또는 소유자 조건으로 메타 테이블 정보를 조회한다.
|
||||
when_to_use: 사용자가 업무 데이터의 테이블명이나 소유 스키마를 확인하려는 경우 사용한다.
|
||||
when_not_to_use: 테이블을 생성하거나 구조를 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 메타에 등록된 테이블 설명 정보만 반환하며 실제 테이블 데이터는 조회하지 않는다.
|
||||
display_description: 메타 시스템에 등록된 테이블 정보를 조회합니다.
|
||||
example_queries: ["고객 기본 테이블을 찾아줘", "계약 관련 테이블 목록을 보여줘", "특정 스키마의 테이블을 조회해줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
tableName: {type: string, description: 테이블 물리명 검색어}
|
||||
tableLogicalName: {type: string, description: 테이블 논리명 검색어}
|
||||
owner: {type: string, description: 테이블 소유 스키마명}
|
||||
additionalProperties: false
|
||||
tags: [메타, 테이블]
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,23 @@
|
||||
name: cmm_template_url
|
||||
display_name: 업무 템플릿 다운로드 URL 조회
|
||||
version: 1.0.0
|
||||
category_key: cmm
|
||||
description:
|
||||
function: 요청한 업무 템플릿 파일을 내려받을 수 있는 URL을 반환한다.
|
||||
when_to_use: 사용자가 엑셀이나 워드 업무 양식의 다운로드 위치를 요청한 경우 사용한다.
|
||||
when_not_to_use: 템플릿 내용을 작성하거나 업로드 또는 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 등록된 템플릿 ID에 대한 다운로드 URL만 반환하며 파일 자체는 반환하지 않는다.
|
||||
display_description: 업무 템플릿을 다운로드할 수 있는 URL을 제공합니다.
|
||||
example_queries: ["청구 양식 다운로드 링크를 알려줘", "업무용 엑셀 템플릿을 받고 싶어", "등록된 문서 양식 위치를 찾아줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
templateId: {type: string, description: 다운로드할 템플릿 식별자}
|
||||
required: [templateId]
|
||||
additionalProperties: false
|
||||
tags: [템플릿, 다운로드]
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,26 @@
|
||||
name: ins_insurance_processor
|
||||
display_name: 보험금 청구 처리
|
||||
version: 1.0.0
|
||||
category_key: ins
|
||||
description:
|
||||
function: 보험금 청구번호, 청구금액과 청구일을 받아 청구 처리 요청을 수행한다.
|
||||
when_to_use: 사용자가 확인된 보험금 청구 정보를 실제 처리계에 접수하려는 경우 사용한다.
|
||||
when_not_to_use: 청구 상태만 조회하거나 필수 정보가 확인되지 않은 경우에는 사용하지 않는다.
|
||||
io_limits: 실행 시 업무 상태가 변경될 수 있으므로 호출 전에 입력값과 사용자 의사를 확인해야 한다.
|
||||
display_description: 확인된 보험금 청구 요청을 처리계에 전달합니다.
|
||||
example_queries: ["확인한 내용으로 보험금 청구를 접수해줘", "이 청구번호의 보험금 처리를 진행해줘", "오늘 날짜로 보험금 청구 요청을 보내줘"]
|
||||
read_only: false
|
||||
destructive: true
|
||||
idempotent: false
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
claimNumber: {type: string, description: 처리할 보험금 청구번호}
|
||||
claimAmount: {type: number, description: 처리할 보험금 청구금액}
|
||||
claimDate: {type: string, description: 청구일자 YYYYMMDD, pattern: "^[0-9]{8}$"}
|
||||
required: [claimNumber, claimAmount, claimDate]
|
||||
additionalProperties: false
|
||||
tags: [보험금, 청구처리]
|
||||
legacy_interface_id: CLAIM0000001
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,35 @@
|
||||
name: oth_onnba3011_call
|
||||
display_name: ONNBA3011 보험 업무 조회
|
||||
version: 1.0.0
|
||||
category_key: oth
|
||||
description:
|
||||
function: ONNBA3011 입력정보를 MCI 전문으로 변환해 보험 업무 결과를 조회한다.
|
||||
when_to_use: 사용자가 ONNBA3011 인터페이스 기준의 보험 계약 또는 지급 정보를 조회하려는 경우 사용한다.
|
||||
when_not_to_use: 인터페이스 입력값을 확인할 수 없거나 보험 정보를 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: CLCNNB00001 인터페이스 규격에 맞는 값만 전달하며 조회 결과를 업무 응답으로 변환한다.
|
||||
display_description: ONNBA3011 업무 정보를 MCI로 조회합니다.
|
||||
example_queries: ["고객의 보험 업무 정보를 조회해줘", "ONNBA3011 기준으로 계약 정보를 확인해줘", "입력한 고객번호의 보험 결과를 알려줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
dalScCd: {type: string, description: 거래 구분 코드}
|
||||
cstSucoRltyCd: {type: string, description: 고객 성공 관계 코드}
|
||||
csNo: {type: string, description: 고객 번호}
|
||||
rdreNo: {type: string, description: 설계사 번호}
|
||||
unfcPvsCalReqYn: {type: string, description: 미확정 지급 계산 요청 여부}
|
||||
kcisPymmTnnrRequest: {type: string, description: KCIS 납입 기간 요청값}
|
||||
lmovYn: {type: string, description: 계약 이동 여부}
|
||||
genPsthApvTrgtYn: {type: string, description: 일반 사후 승인 대상 여부}
|
||||
ircoLmovEcpbTrgtYn: {type: string, description: 계약 이동 예외 대상 여부}
|
||||
digCalYn: {type: string, description: 디지털 계산 여부}
|
||||
prbuIciDigCalYn: {type: string, description: 상품별 디지털 계산 여부}
|
||||
unfcPrbuIrcoAddu: {type: object, description: 미확정 상품 추가 정보, additionalProperties: true}
|
||||
sucoIspaBasDto: {type: object, description: 성공 심사 기본 정보, additionalProperties: true}
|
||||
additionalProperties: false
|
||||
tags: [보험, MCI]
|
||||
legacy_interface_id: CLCNNB00001
|
||||
required_env_keys: [GLOW_COMMUNICATION_MCI_HOST, GLOW_COMMUNICATION_MCI_PORT]
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,22 @@
|
||||
name: smp_exchange_inquiry
|
||||
display_name: 실시간 환율 조회
|
||||
version: 1.0.0
|
||||
category_key: smp
|
||||
description:
|
||||
function: 통화코드를 기준으로 현재 환율 정보를 조회한다.
|
||||
when_to_use: 사용자가 USD, EUR, JPY 등 특정 통화의 환율을 확인하려는 경우 사용한다.
|
||||
when_not_to_use: 환전 거래를 실행하거나 과거 환율 통계를 분석하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 지원되는 통화코드의 조회 시점 환율만 반환하며 실제 환전 기능은 제공하지 않는다.
|
||||
display_description: 지정한 통화의 현재 환율을 조회합니다.
|
||||
example_queries: ["오늘 달러 환율을 알려줘", "엔화 환율이 얼마인지 조회해줘", "유로 환율을 확인해줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
currencyCode: {type: string, description: 조회할 ISO 통화코드, pattern: "^[A-Z]{3}$"}
|
||||
additionalProperties: false
|
||||
tags: [환율, 금융]
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,22 @@
|
||||
name: smp_quote_daily
|
||||
display_name: 오늘의 명언 조회
|
||||
version: 1.0.0
|
||||
category_key: smp
|
||||
description:
|
||||
function: 선택한 카테고리에 맞는 오늘의 명언 한 건을 조회한다.
|
||||
when_to_use: 사용자가 명언이나 짧은 동기부여 문구를 요청한 경우 사용한다.
|
||||
when_not_to_use: 업무 데이터 조회나 사실 검증이 필요한 질문에는 사용하지 않는다.
|
||||
io_limits: 등록된 명언 데이터 중 한 건만 반환하며 출처 정보가 없을 수 있다.
|
||||
display_description: 카테고리에 맞는 오늘의 명언을 제공합니다.
|
||||
example_queries: ["오늘 힘이 되는 말을 알려줘", "업무 시작 전에 명언 하나 보여줘", "성공에 관한 짧은 문구를 추천해줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: false
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
category: {type: string, description: 조회할 명언 카테고리}
|
||||
additionalProperties: false
|
||||
tags: [명언, 콘텐츠]
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,22 @@
|
||||
name: smp_team_list
|
||||
display_name: MCP·TOOL 파트 구성원 조회
|
||||
version: 1.0.0
|
||||
category_key: smp
|
||||
description:
|
||||
function: 신한라이프 AX 추진팀과 MCP·TOOL 파트 구성원 및 직급을 조회한다.
|
||||
when_to_use: 사용자가 프로젝트 담당자나 팀별 구성원 정보를 묻는 경우 사용한다.
|
||||
when_not_to_use: 인사 개인정보나 연락처 또는 조직 변경 이력을 요청하는 경우에는 사용하지 않는다.
|
||||
io_limits: 사전에 등록된 구성원의 이름과 직급만 반환하며 민감한 인사정보는 포함하지 않는다.
|
||||
display_description: 신한라이프 MCP·TOOL 파트 담당자와 구성원을 조회합니다.
|
||||
example_queries: ["MCP 팀 담당자를 알려줘", "TOOL 파트 구성원이 누구인지 보여줘", "AX 추진팀 담당자를 찾아줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
teamName: {type: string, description: 조회할 팀 이름 또는 전체}
|
||||
additionalProperties: false
|
||||
tags: [조직, 담당자]
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,23 @@
|
||||
name: smp_weather_inquiry
|
||||
display_name: 도시 날씨 조회
|
||||
version: 1.0.0
|
||||
category_key: smp
|
||||
description:
|
||||
function: 도시명을 기준으로 현재 날씨, 온도와 풍속을 조회한다.
|
||||
when_to_use: 사용자가 특정 도시의 현재 기상 정보를 요청한 경우 사용한다.
|
||||
when_not_to_use: 장기 예보나 기상 특보 또는 공식 재난정보가 필요한 경우에는 사용하지 않는다.
|
||||
io_limits: 입력한 도시의 현재 관측 기반 샘플 정보만 반환한다.
|
||||
display_description: 지정한 도시의 현재 날씨 정보를 조회합니다.
|
||||
example_queries: ["서울 날씨를 알려줘", "부산의 현재 온도를 조회해줘", "제주도 바람이 얼마나 부는지 알려줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
city: {type: string, description: 날씨를 조회할 도시명}
|
||||
required: [city]
|
||||
additionalProperties: false
|
||||
tags: [날씨, 조회]
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,24 @@
|
||||
name: sol_request_detail
|
||||
display_name: SOL 의뢰서 상세 조회
|
||||
version: 1.0.0
|
||||
category_key: sol
|
||||
description:
|
||||
function: SOL 의뢰서 식별자를 기준으로 의뢰서 상세 내용을 조회한다.
|
||||
when_to_use: 사용자가 특정 SOL 의뢰서의 상세 내용과 진행 정보를 확인하려는 경우 사용한다.
|
||||
when_not_to_use: 의뢰서 목록만 찾거나 의뢰서를 변경 또는 처리하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 정확한 의뢰서 ID가 필요하며 등록된 상세 정보만 반환한다.
|
||||
display_description: SOL 의뢰서 한 건의 상세 정보를 조회합니다.
|
||||
example_queries: ["이 SOL 의뢰서 상세를 보여줘", "의뢰서 ID로 처리 내용을 확인해줘", "선택한 의뢰서의 상세 정보를 알려줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
srId: {type: string, description: 상세 조회할 SOL 의뢰서 ID}
|
||||
required: [srId]
|
||||
additionalProperties: false
|
||||
tags: [SOL, 의뢰서]
|
||||
legacy_interface_id: SOLG00000002
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,25 @@
|
||||
name: sol_request_list
|
||||
display_name: SOL 의뢰서 목록 조회
|
||||
version: 1.0.0
|
||||
category_key: sol
|
||||
description:
|
||||
function: 진행상태, 조회기간과 조회대상 조건으로 SOL 의뢰서 목록을 조회한다.
|
||||
when_to_use: 사용자가 자신의 SOL 의뢰서나 상태별 의뢰서 목록을 확인하려는 경우 사용한다.
|
||||
when_not_to_use: 특정 의뢰서의 상세 내용만 보거나 의뢰서를 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 입력 조건에 해당하는 의뢰서 요약 목록만 반환한다.
|
||||
display_description: 조건에 맞는 SOL 의뢰서 목록을 조회합니다.
|
||||
example_queries: ["진행 중인 SOL 의뢰서를 보여줘", "최근 한 달간 내 의뢰서를 조회해줘", "완료된 의뢰서 목록을 알려줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
status: {type: string, description: 조회할 의뢰서 진행상태}
|
||||
period: {type: string, description: 조회할 기간 조건}
|
||||
target: {type: string, description: 나의 업무 또는 전체 조회대상}
|
||||
additionalProperties: false
|
||||
tags: [SOL, 의뢰서]
|
||||
legacy_interface_id: SOLG00000001
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claimNo": {
|
||||
"type": "string",
|
||||
"description": "청구번호. CLM 다음 숫자 13자리 형식이다.",
|
||||
"pattern": "^CLM[0-9]{13}$",
|
||||
"examples": ["CLM2026070100123"]
|
||||
},
|
||||
"contractNo": {
|
||||
"type": "string",
|
||||
"description": "계약번호. 숫자 11자리 형식이다.",
|
||||
"pattern": "^[0-9]{11}$",
|
||||
"examples": ["10023456789"]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED",
|
||||
"APPROVED", "PAID", "REJECTED", "WITHDRAWN"
|
||||
]
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": false,
|
||||
"anyOf": [
|
||||
{ "required": ["claimNo"] },
|
||||
{ "required": ["contractNo"] }
|
||||
]
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"description": "Claim search response. This schema intentionally excludes employee identifiers, customer names, contact details, account information, and other PII.",
|
||||
"properties": {
|
||||
"resultCode": {
|
||||
"type": "string",
|
||||
"enum": ["SUCCESS", "FAILURE"],
|
||||
"description": "Machine-readable execution result code."
|
||||
},
|
||||
"resultLabel": {
|
||||
"type": "string",
|
||||
"description": "User-readable label for resultCode."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["RECEIVED", "REVIEWING", "ADDITIONAL_DOC_REQUIRED", "APPROVED", "PAID", "REJECTED", "WITHDRAWN"],
|
||||
"description": "Current claim processing status code."
|
||||
},
|
||||
"statusLabel": {
|
||||
"type": "string",
|
||||
"description": "User-readable label for status."
|
||||
},
|
||||
"approvedAmount": {
|
||||
"anyOf": [
|
||||
{ "type": "number", "minimum": 0 },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"description": "Approved amount. It is null before review and must not be interpreted as zero."
|
||||
},
|
||||
"rejectionReason": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 200,
|
||||
"description": "Has a value only when status is REJECTED. It is null for every other status."
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Claim summaries ordered by receivedDate descending. No personally identifiable information is included.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "description": "Claim status code." },
|
||||
"statusLabel": { "type": "string", "description": "User-readable label for status." },
|
||||
"receivedDate": { "type": "string", "format": "date", "description": "Claim received date." },
|
||||
"approvedAmount": {
|
||||
"anyOf": [
|
||||
{ "type": "number", "minimum": 0 },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"description": "Null before review; do not interpret as zero."
|
||||
}
|
||||
},
|
||||
"required": ["status", "statusLabel", "receivedDate"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"hasMore": {
|
||||
"type": "boolean",
|
||||
"description": "True when additional results exist beyond this response."
|
||||
},
|
||||
"totalCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Total number of matched claims."
|
||||
}
|
||||
},
|
||||
"required": ["resultCode", "resultLabel", "status", "statusLabel", "items", "hasMore", "totalCount"],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "status": { "const": "REJECTED" } },
|
||||
"required": ["status"]
|
||||
},
|
||||
"then": {
|
||||
"properties": { "rejectionReason": { "type": "string", "minLength": 1 } },
|
||||
"required": ["rejectionReason"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.lib.util.JsonSchemaGenerator;
|
||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.usecase.impl.ClaimSearchSchemaSampleUseCaseImpl;
|
||||
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;import io.shinhanlife.dap.mcc.biz.cmm.usecase.ClaimSearchSchemaSampleUseCase;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
|
||||
class ClaimSearchRequestSchemaTest {
|
||||
|
||||
@Test
|
||||
void generatesClaimOrContractSearchSchema() {
|
||||
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(ClaimSearchRequest.class);
|
||||
Map<String, Map<String, Object>> properties = properties(schema);
|
||||
|
||||
assertEquals(false, schema.get("additionalProperties"));
|
||||
assertEquals("^CLM[0-9]{13}$", properties.get("claimNo").get("pattern"));
|
||||
assertEquals("^[1-9][0-9]?$|^50$", properties.get("size").get("pattern"));
|
||||
assertEquals("20", properties.get("size").get("default"));
|
||||
assertFalse(schema.containsKey("anyOf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesSchemaFromToolModuleResource() throws Exception {
|
||||
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||
McpTool function = method.getAnnotation(McpTool.class);
|
||||
ToolHint hint = method.getAnnotation(ToolHint.class);
|
||||
|
||||
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
||||
.resolve(function, hint, ClaimSearchRequest.class);
|
||||
|
||||
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
||||
assertTrue(schema.containsKey("anyOf"));
|
||||
assertEquals(50, property(schema, "size").get("maximum"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesOutputSchemaFromToolModuleResource() throws Exception {
|
||||
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||
McpTool function = method.getAnnotation(McpTool.class);
|
||||
ToolHint hint = method.getAnnotation(ToolHint.class);
|
||||
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
||||
.resolveOutput(function, ClaimSearchResponse.class, hint);
|
||||
assertEquals("https://json-schema.org/draft/2020-12/schema", schema.get("$schema"));
|
||||
assertTrue(properties(schema).containsKey("resultCode"));
|
||||
assertTrue(properties(schema).containsKey("statusLabel"));
|
||||
assertTrue(properties(schema).containsKey("hasMore"));
|
||||
assertTrue(schema.containsKey("allOf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sampleResponseConformsToOutputSchema() throws Exception {
|
||||
Method method = ClaimSearchSchemaSampleUseCase.class
|
||||
.getDeclaredMethod("search", ClaimSearchRequest.class);
|
||||
Map<String, Object> schema = new ToolSchemaResolver(new ObjectMapper())
|
||||
.resolveOutput(method.getAnnotation(McpTool.class), ClaimSearchResponse.class,
|
||||
method.getAnnotation(ToolHint.class));
|
||||
|
||||
ClaimSearchResponse response = new ClaimSearchSchemaSampleUseCaseImpl().search(new ClaimSearchRequest());
|
||||
ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
|
||||
|
||||
assertTrue(validator.validateValue(schema, response).isEmpty());
|
||||
}
|
||||
@Test
|
||||
void sampleResponseConformsToAutomaticallyGeneratedOutputSchema() throws Exception {
|
||||
Map<String, Object> schema = JsonSchemaGenerator.generateSchema(ClaimSearchResponse.class);
|
||||
ClaimSearchResponse response = new ClaimSearchSchemaSampleUseCaseImpl().search(new ClaimSearchRequest());
|
||||
ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
|
||||
|
||||
assertTrue(validator.validateValue(schema, response).isEmpty());
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Map<String, Object>> properties(Map<String, Object> schema) {
|
||||
return (Map<String, Map<String, Object>>) schema.get("properties");
|
||||
}
|
||||
|
||||
private Map<String, Object> property(Map<String, Object> schema, String name) {
|
||||
return properties(schema).get(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.dap.mcc.biz.ins.usecase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.ins.dto.InsuranceClaimProcessorResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class InsuranceClaimProcessorUseCaseTest {
|
||||
|
||||
@Test
|
||||
void createsToolRequestAndResponseDtos() {
|
||||
assertNotNull(new InsuranceClaimProcessorRequest());
|
||||
assertNotNull(new InsuranceClaimProcessorResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.converter;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverResponse;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.memo.io.MemoListRetrieverHttpRequest;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.memo.io.MemoListRetrieverHttpResponse;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface MemoListRetrieverConverter {
|
||||
// Field names differ? Add mappings like this before the method.
|
||||
// @Mapping(source = "sourceField", target = "targetField")
|
||||
MemoListRetrieverHttpRequest toHttpRequest(MemoListRetrieverRequest request);
|
||||
MemoListRetrieverResponse toResponse(MemoListRetrieverHttpResponse httpResponse);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class MemoListRetrieverRequest {
|
||||
@Schema(description = "조회할 의뢰서 상태", example = "OPEN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String memoStatus;
|
||||
|
||||
@Schema(description = "검색 키워드", example = "프로젝트", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String searchKeyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class MemoListRetrieverResponse {
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverResponse;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcc.biz.cmm.usecase
|
||||
* @className MemoListRetrieverUseCase
|
||||
* @description AX HUB ?쒖뒪??泥섎━ ?대옒??
|
||||
* @author Admin
|
||||
* @create 2026.08.11
|
||||
* <pre>
|
||||
* ---------- 媛쒖젙?대젰 ----------
|
||||
* ?섏젙?? ?섏젙?? ?섏젙?댁슜
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.08.11 Admin 理쒖큹?앹꽦
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface MemoListRetrieverUseCase {
|
||||
|
||||
@McpTool(name = "cmm_memo_retriever", title = "의뢰서 목록 조회", description = "의뢰서 목록을 조회하여 의뢰 정보를 반환합니다.")
|
||||
@ToolHint(register = false, categoryKey = "cmm", mappingId = "MEMO0000001")
|
||||
MemoListRetrieverResponse execute(MemoListRetrieverRequest req);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.shinhanlife.dap.mcc.biz.cmm.usecase.impl;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.converter.MemoListRetrieverConverter;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.dto.MemoListRetrieverResponse;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.memo.MemoClient;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.memo.io.MemoListRetrieverHttpRequest;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.memo.io.MemoListRetrieverHttpResponse;
|
||||
import io.shinhanlife.dap.mcc.biz.cmm.usecase.MemoListRetrieverUseCase;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MemoListRetrieverUseCaseImpl implements MemoListRetrieverUseCase {
|
||||
|
||||
private final MemoListRetrieverConverter converter;
|
||||
private final MemoClient memoClient;
|
||||
|
||||
@Override
|
||||
public MemoListRetrieverResponse execute(MemoListRetrieverRequest req) {
|
||||
MemoListRetrieverHttpRequest httpRequest = converter.toHttpRequest(req);
|
||||
MemoListRetrieverHttpResponse httpResponse = memoClient.call(httpRequest, MemoListRetrieverHttpResponse.class);
|
||||
|
||||
MemoListRetrieverResponse response = converter.toResponse(httpResponse);
|
||||
response.setResultCode("SUCCESS");
|
||||
response.setResultMessage("HTTP API call completed.");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.converter;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.EmployeeSearchRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.EmployeeSearchResponse;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.io.EmployeeSearchHttpRequest;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.io.EmployeeSearchHttpResponse;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface EmployeeSearchConverter {
|
||||
EmployeeSearchHttpRequest toHttpRequest(EmployeeSearchRequest request);
|
||||
EmployeeSearchResponse toResponse(EmployeeSearchHttpResponse httpResponse);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class EmployeeSearchRequest {
|
||||
@Schema(description = "조회할 사번", example = "EMP10001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String employeeId;
|
||||
|
||||
@Schema(description = "직원명. 사번 없이 이름으로 조회할 때 사용", example = "홍길동")
|
||||
private String employeeName;
|
||||
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class EmployeeSearchResponse {
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
@Schema(description = "직원명", example = "홍길동")
|
||||
private String employeeName;
|
||||
|
||||
@Schema(description = "소속 부서명", example = "AX추진팀")
|
||||
private String departmentName;
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.usecase;
|
||||
|
||||
import org.springaicommunity.mcp.annotation.McpTool;
|
||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.EmployeeSearchRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.EmployeeSearchResponse;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dap.mcc.biz.smp.usecase
|
||||
* @className EmployeeSearchUseCase
|
||||
* @description AX HUB ?쒖뒪??泥섎━ ?대옒??
|
||||
* @author jade
|
||||
* @create 2026.08.11
|
||||
* <pre>
|
||||
* ---------- 媛쒖젙?대젰 ----------
|
||||
* ?섏젙?? ?섏젙?? ?섏젙?댁슜
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.08.11 jade 理쒖큹?앹꽦
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
public interface EmployeeSearchUseCase {
|
||||
|
||||
@McpTool(name = "smp_employee_search", title = "직원 정보 조회", description = "사번 또는 직원명을 기준으로 직원 정보를 조회합니다.")
|
||||
@ToolHint(register = false, categoryKey = "smp", mappingId = "CLCNNB00001")
|
||||
EmployeeSearchResponse execute(EmployeeSearchRequest req);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.biz.smp.usecase.impl;
|
||||
|
||||
import io.shinhanlife.dap.mcc.biz.smp.converter.EmployeeSearchConverter;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.EmployeeSearchRequest;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.dto.EmployeeSearchResponse;
|
||||
import io.shinhanlife.dap.mcc.biz.smp.usecase.EmployeeSearchUseCase;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleClient;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.io.EmployeeSearchHttpRequest;
|
||||
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.io.EmployeeSearchHttpResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmployeeSearchUseCaseImpl implements EmployeeSearchUseCase {
|
||||
|
||||
private final EmployeeSearchConverter converter;
|
||||
private final SampleClient sampleClient;
|
||||
|
||||
@Override
|
||||
public EmployeeSearchResponse execute(EmployeeSearchRequest request) {
|
||||
EmployeeSearchHttpRequest httpRequest = converter.toHttpRequest(request);
|
||||
EmployeeSearchHttpResponse httpResponse = sampleClient.call(httpRequest, EmployeeSearchHttpResponse.class);
|
||||
|
||||
EmployeeSearchResponse response = converter.toResponse(httpResponse);
|
||||
response.setResultCode("SUCCESS");
|
||||
response.setResultMessage("HTTP API call completed.");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.sample;
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.memo;
|
||||
|
||||
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -6,8 +6,8 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SampleClient {
|
||||
private static final String API_NAME = "sample";
|
||||
public class MemoClient {
|
||||
private static final String API_NAME = "memo";
|
||||
|
||||
private final AxhubHttpComponent http;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.memo.io;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class MemoListRetrieverHttpRequest {
|
||||
@Schema(description = "조회할 의뢰서 상태", example = "OPEN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String memoStatus;
|
||||
|
||||
@Schema(description = "검색 키워드", example = "프로젝트", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String searchKeyword;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.memo.io;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class MemoListRetrieverHttpResponse {
|
||||
private String resultCode;
|
||||
|
||||
private String resultMessage;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.sample.io;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class EmployeeSearchHttpRequest {
|
||||
private String employeeId;
|
||||
private String employeeName;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package io.shinhanlife.dap.mcc.infra.itrf.http.sample.io;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class EmployeeSearchHttpResponse {
|
||||
private String resultCode;
|
||||
private String employeeName;
|
||||
private String departmentName;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"resultCode": "SUCCESS"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
name: cmm_claim_search
|
||||
display_name: 보험금 청구 상태 조회
|
||||
version: 1.0.0
|
||||
category_key: cmm
|
||||
description:
|
||||
function: 청구번호 또는 계약번호로 보험금 청구 상태와 심사 결과를 조회한다.
|
||||
when_to_use: 사용자가 기존 보험금 청구의 진행 상태나 지급 결과를 확인하려는 경우 사용한다.
|
||||
when_not_to_use: 보험금 청구를 새로 접수하거나 기존 청구 내용을 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 청구번호 또는 계약번호 중 하나 이상이 필요하며 조회 결과만 반환한다.
|
||||
display_description: 보험금 청구 상태와 심사 결과를 조회합니다.
|
||||
example_queries: ["내 보험금 청구가 어디까지 진행됐는지 알려줘", "계약번호로 최근 청구 상태를 확인해줘", "청구 심사 결과가 나왔는지 조회해줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
claimNo: {type: string, description: 조회할 보험금 청구번호}
|
||||
contractNo: {type: string, description: 조회할 보험계약 번호}
|
||||
additionalProperties: false
|
||||
tags: [보험금, 청구조회]
|
||||
legacy_interface_id: CLCNNB00001
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
@@ -0,0 +1,24 @@
|
||||
name: cmm_memo_retriever
|
||||
display_name: 의뢰서 목록 조회
|
||||
version: 1.0.0
|
||||
category_key: cmm
|
||||
description:
|
||||
function: 상태와 검색어 조건으로 의뢰서 목록을 조회한다.
|
||||
when_to_use: 사용자가 등록된 의뢰서의 목록이나 처리 현황을 찾으려는 경우 사용한다.
|
||||
when_not_to_use: 의뢰서를 신규 등록하거나 내용을 변경하려는 경우에는 사용하지 않는다.
|
||||
io_limits: 선택 조건에 해당하는 의뢰서 요약 목록만 반환한다.
|
||||
display_description: 조건에 맞는 의뢰서 목록을 조회합니다.
|
||||
example_queries: ["처리 중인 의뢰서를 보여줘", "지난번 계약 관련 의뢰서를 찾아줘", "완료된 의뢰서 목록을 조회해줘"]
|
||||
read_only: true
|
||||
destructive: false
|
||||
idempotent: true
|
||||
parameters_schema:
|
||||
type: object
|
||||
properties:
|
||||
memoStatus: {type: string, description: 조회할 의뢰서 처리 상태}
|
||||
searchKeyword: {type: string, description: 의뢰서 제목 또는 내용 검색어}
|
||||
additionalProperties: false
|
||||
tags: [의뢰서, 목록조회]
|
||||
legacy_interface_id: MEMO0000001
|
||||
required_env_keys: []
|
||||
owner_org: MCP_TOOL
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user