10 Commits

Author SHA1 Message Date
jade
017812cd29 feat: apply tool schema v17 metadata
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 22s
2026-08-12 15:46:57 +09:00
jade
97a56efddf docs: define tool schema v17 migration 2026-08-12 13:12:19 +09:00
binzz
39c59be5a2 Merge remote-tracking branch 'origin/main' into feature/09863413_psb
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m59s
2026-08-12 10:43:26 +09:00
binzz
ccabd1187b feat: add Excel document generator 2026-08-12 10:39:09 +09:00
윤희준
3e553441ba ci: trigger build after static docker cli fix
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m3s
2026-08-12 10:34:58 +09:00
jade
b60b933a08 refactor: remove claim schema sample tool
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 1m56s
2026-08-12 00:10:04 +09:00
jade
8cbdd2ecad feat: add insurance claim HTTP tool scaffold
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
2026-08-11 23:59:33 +09:00
jade
6662641903 refactor: remove sample HTTP tool integration
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
2026-08-11 23:41:57 +09:00
jade
0e27937687 feat: add scaffold HTTP mock support
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
2026-08-11 23:12:30 +09:00
jade
45e4332adb feat: add AI scaffold drafts
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled
2026-08-11 22:54:02 +09:00
112 changed files with 4186 additions and 917 deletions

View File

@@ -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 이미지 생성 전에 빌드가 실패합니다.

View File

@@ -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')
}
}

View File

@@ -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'

View File

@@ -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) {
}

View File

@@ -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());
}
}

View File

@@ -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) {
}
}

View File

@@ -0,0 +1,4 @@
package io.shinhanlife.dap.mcg.document;
public record GeneratedDocument(String fileName, byte[] content) {
}

View File

@@ -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) {
}
}

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -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("한 개 이상의 시트");
}
}

View File

@@ -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"));
}
}

View File

@@ -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'

View File

@@ -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));
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}

View File

@@ -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);

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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();

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
{
"resultCode": "SUCCESS"
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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) {
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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) {
}

View File

@@ -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"
}
}
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,4 @@
{
"resultCode" : "SUCCESS",
"claimId" : "CLM20230001"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"] }
]
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
{
"resultCode": "SUCCESS"
}

View File

@@ -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

View File

@@ -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