feat: apply tool schema v17 metadata
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 22s

This commit is contained in:
jade
2026-08-12 15:46:57 +09:00
parent 97a56efddf
commit 017812cd29
45 changed files with 1554 additions and 50 deletions

View File

@@ -269,3 +269,38 @@ Tool 관련 공통 기능은 `dap-was-*` 모듈명만 기준으로 동작합니
- Tool Scaffold는 Pod 이름을 Tool 함수명에 포함하지 않습니다. 함수명은 `도메인_비즈니스_행위` 형식입니다. 예: `cmm_notification_send` - Tool Scaffold는 Pod 이름을 Tool 함수명에 포함하지 않습니다. 함수명은 `도메인_비즈니스_행위` 형식입니다. 예: `cmm_notification_send`
- Pod Scaffold와 Gateway Scaffold 화면/API의 모듈 목록도 `dap-was-*` 명칭으로 통일되어 있습니다. - Pod Scaffold와 Gateway Scaffold 화면/API의 모듈 목록도 `dap-was-*` 명칭으로 통일되어 있습니다.
- Tool Source Update 기능은 `dap-was-*` 아래의 `*UseCase.java`를 검색합니다. - 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 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 { subprojects {
// 배포용 Spring Boot JAR 생성 전에 Tool 이름 중복 검증을 강제합니다. // 배포용 Spring Boot JAR 생성 전에 Tool 이름 중복 검증을 강제합니다.
tasks.matching { it.name == 'bootJar' }.configureEach { tasks.matching { it.name == 'bootJar' }.configureEach {
dependsOn rootProject.tasks.named('validateMcpToolNames') dependsOn rootProject.tasks.named('validateMcpToolNames')
dependsOn rootProject.tasks.named('validateToolSchemaV17')
} }
} }

View File

@@ -98,8 +98,19 @@ public class ScaffoldingController {
if (inputFields.isEmpty()) { if (inputFields.isEmpty()) {
inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false)); inputFields = List.of(new ToolScaffolder.FieldDefinition("query", "String", "Search query", "example", false));
} }
ToolScaffolder.ToolDefinitionOptions definitionOptions = new ToolScaffolder.ToolDefinitionOptions(
return ToolScaffolder.scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, date, register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields, httpApiName); req.get("functionDescription"),
req.get("whenToUse"),
req.get("whenNotToUse"),
req.get("ioLimits"),
req.get("displayDescription"),
parseDelimited(req.get("exampleQueries")),
parseDelimited(req.get("tags")),
req.get("ownerOrg"));
return ToolScaffolder.scaffold(baseName, interfaceId, title, description, group, routingType,
moduleName, author, date, register, clientSystemCode, inputSchemaResource,
outputSchemaResource, inputFields, outputFields, httpApiName, definitionOptions);
} catch (Exception e) { } catch (Exception e) {
return "오류 발생: " + e.getMessage(); return "오류 발생: " + e.getMessage();
} }
@@ -151,9 +162,11 @@ public class ScaffoldingController {
Generate an MCP Tool scaffold from the user request. Generate an MCP Tool scaffold from the user request.
Return JSON only. Do not add Markdown, explanations, or code fences. Return JSON only. Do not add Markdown, explanations, or code fences.
The response must have this exact shape: 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","inputFields":[{"name":"camelCaseName","type":"String","description":"short description","example":"example","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"result code","example":"SUCCESS","required":true}]} {"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. 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. 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. 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. 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. Do not generate interfaceId or clientSystemCode; those must come from a real integration contract.
@@ -163,15 +176,7 @@ public class ScaffoldingController {
String response = generateAiContent(prompt, req.get("model")); String response = generateAiContent(prompt, req.get("model"));
ToolDraft draft = objectMapper.readValue(stripCodeFence(response), ToolDraft.class); ToolDraft draft = objectMapper.readValue(stripCodeFence(response), ToolDraft.class);
ToolDraft validatedDraft = validateToolDraft(draft); ToolDraft validatedDraft = validateToolDraft(draft);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(validatedDraft);
"baseName", validatedDraft.baseName(),
"title", validatedDraft.title(),
"description", validatedDraft.description(),
"categoryKey", validatedDraft.categoryKey(),
"routingType", validatedDraft.routingType(),
"httpApiName", validatedDraft.httpApiName(),
"inputFields", validatedDraft.inputFields(),
"outputFields", validatedDraft.outputFields()));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", "AI Tool 초안 생성 실패: " + safeMessage(e))); return ResponseEntity.internalServerError().body(Map.of("error", "AI Tool 초안 생성 실패: " + safeMessage(e)));
} }
@@ -219,6 +224,17 @@ public class ScaffoldingController {
return 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) { private List<ToolScaffolder.FieldDefinition> validateFields(List<ToolScaffolder.FieldDefinition> source) {
if (source == null || source.isEmpty()) { if (source == null || source.isEmpty()) {
throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다."); throw new IllegalArgumentException("AI가 필드를 생성하지 않았습니다.");
@@ -268,10 +284,37 @@ public class ScaffoldingController {
if (title.isBlank() || description.isBlank()) { if (title.isBlank() || description.isBlank()) {
throw new IllegalArgumentException("AI가 Tool 제목 또는 설명을 생성하지 않았습니다."); 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, 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())); 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) { private String generateAiContent(String prompt, String requestedModel) {
return chatClientBuilder.build().prompt() return chatClientBuilder.build().prompt()
.user(prompt) .user(prompt)
@@ -306,7 +349,10 @@ public class ScaffoldingController {
} }
private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType, private record ToolDraft(String baseName, String title, String description, String categoryKey, String routingType,
String httpApiName, List<ToolScaffolder.FieldDefinition> inputFields, 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) { List<ToolScaffolder.FieldDefinition> outputFields) {
} }
} }

View File

@@ -16,6 +16,7 @@ package io.shinhanlife.dap.mcg.sync;
* </pre> * </pre>
*/ */
import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.lib.mcp.ToolMetadataMcpMapper;
import io.shinhanlife.dap.mcg.service.ExecuteService; import io.shinhanlife.dap.mcg.service.ExecuteService;
import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
@@ -48,18 +49,7 @@ public class RegistryMcpToolSpecificationFactory {
* Registry Entry 하나를 MCP SDK의 stateless sync Tool specification으로 변환합니다. * Registry Entry 하나를 MCP SDK의 stateless sync Tool specification으로 변환합니다.
*/ */
public McpServerFeatures.SyncToolSpecification create(ToolMetadata entry) { public McpServerFeatures.SyncToolSpecification create(ToolMetadata entry) {
McpSchema.Tool tool = McpSchema.Tool.builder() McpSchema.Tool tool = ToolMetadataMcpMapper.toTool(entry);
.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();
return McpServerFeatures.SyncToolSpecification.builder() return McpServerFeatures.SyncToolSpecification.builder()
.tool(tool) .tool(tool)

View File

@@ -868,6 +868,43 @@
<div class="input-hint">LLM call guidance: purpose, when to use it, required conditions, and exclusions.</div> <div class="input-hint">LLM call guidance: purpose, when to use it, required conditions, and exclusions.</div>
</div> </div>
</div> </div>
<div class="mb-3 p-3 rounded" style="background:#18181b; border:1px solid #3f3f46;">
<div class="form-label mb-2">Tool Schema V17 Metadata</div>
<div class="input-hint mb-3">LLM이 Tool을 올바르게 선택하도록 기능·사용 조건·제외 조건·입출력 제한을 분리해 입력합니다.</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Function Description</label>
<textarea class="form-control" name="functionDescription" rows="2" placeholder="이 Tool이 수행하는 핵심 기능"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Display Description</label>
<textarea class="form-control" name="displayDescription" rows="2" placeholder="Portal에 보여줄 짧은 설명"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">When To Use</label>
<textarea class="form-control" name="whenToUse" rows="2" placeholder="어떤 사용자 요청에서 이 Tool을 사용하는지"></textarea>
</div>
<div class="col-md-6">
<label class="form-label">When Not To Use</label>
<textarea class="form-control" name="whenNotToUse" rows="2" placeholder="이 Tool을 사용하면 안 되는 조건"></textarea>
</div>
<div class="col-md-12">
<label class="form-label">I/O Limits</label>
<textarea class="form-control" name="ioLimits" rows="2" placeholder="허용되는 입력, 반환 범위, 건수 제한 등"></textarea>
</div>
<div class="col-md-8">
<label class="form-label">Example Queries (3~10)</label>
<textarea class="form-control" name="exampleQueries" rows="3" placeholder="한 줄에 하나씩 입력\n예: 사번 10001 직원 정보를 조회해줘"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Tags</label>
<input type="text" class="form-control" name="tags" placeholder="employee, search">
<label class="form-label mt-3">Owner Organization</label>
<input type="text" class="form-control" name="ownerOrg" value="MCP_TOOL">
</div>
</div>
</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-6"> <div class="col-md-6">
@@ -1692,6 +1729,16 @@
form.elements.categoryKey.value = result.categoryKey || ''; form.elements.categoryKey.value = result.categoryKey || '';
form.elements.routingType.value = result.routingType || 'HTTP'; form.elements.routingType.value = result.routingType || 'HTTP';
form.elements.httpApiName.value = result.httpApiName || ''; form.elements.httpApiName.value = result.httpApiName || '';
form.elements.functionDescription.value = result.functionDescription || '';
form.elements.displayDescription.value = result.displayDescription || '';
form.elements.whenToUse.value = result.whenToUse || '';
form.elements.whenNotToUse.value = result.whenNotToUse || '';
form.elements.ioLimits.value = result.ioLimits || '';
form.elements.exampleQueries.value = Array.isArray(result.exampleQueries)
? result.exampleQueries.join('\n') : (result.exampleQueries || '');
form.elements.tags.value = Array.isArray(result.tags)
? result.tags.join(', ') : (result.tags || '');
form.elements.ownerOrg.value = result.ownerOrg || 'MCP_TOOL';
document.getElementById('inputFields').value = JSON.stringify(result.inputFields || [], null, 2); document.getElementById('inputFields').value = JSON.stringify(result.inputFields || [], null, 2);
document.getElementById('outputFields').value = JSON.stringify(result.outputFields || [], null, 2); document.getElementById('outputFields').value = JSON.stringify(result.outputFields || [], null, 2);
alert('Tool 초안을 채웠습니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 입력해주세요.'); alert('Tool 초안을 채웠습니다. Legacy Interface ID와 Client System Code는 실제 연계 명세를 확인해 입력해주세요.');

View File

@@ -1,12 +1,15 @@
package io.shinhanlife.dap.biz.mcp.gateway.sync; package io.shinhanlife.dap.biz.mcp.gateway.sync;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcg.sync.RegistryMcpToolSpecificationFactory; import io.shinhanlife.dap.mcg.sync.RegistryMcpToolSpecificationFactory;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class RegistryMcpToolSpecificationFactoryTest { class RegistryMcpToolSpecificationFactoryTest {
@@ -15,7 +18,15 @@ class RegistryMcpToolSpecificationFactoryTest {
void exposesMetadataBehaviorHintsInMcpToolSpecification() { void exposesMetadataBehaviorHintsInMcpToolSpecification() {
ToolMetadata metadata = ToolMetadata.builder() ToolMetadata metadata = ToolMetadata.builder()
.name("customer_lookup") .name("customer_lookup")
.displayName("고객 조회")
.description("Looks up customer information") .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) .readOnlyHint(true)
.destructiveHint(false) .destructiveHint(false)
.idempotentHint(true) .idempotentHint(true)
@@ -29,5 +40,9 @@ class RegistryMcpToolSpecificationFactoryTest {
assertFalse(tool.annotations().destructiveHint()); assertFalse(tool.annotations().destructiveHint());
assertTrue(tool.annotations().idempotentHint()); assertTrue(tool.annotations().idempotentHint());
assertFalse(tool.annotations().openWorldHint()); 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

@@ -31,7 +31,7 @@ class ScaffoldingControllerToolDraftTest {
when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec); when(requestSpec.options(any(ChatOptions.class))).thenReturn(requestSpec);
when(requestSpec.call()).thenReturn(responseSpec); when(requestSpec.call()).thenReturn(responseSpec);
when(responseSpec.content()).thenReturn(""" when(responseSpec.content()).thenReturn("""
{"baseName":"CustomerContractStatus","title":"계약 상태 조회","description":"고객번호로 계약 상태를 조회합니다.","categoryKey":"cmm","routingType":"HTTP","httpApiName":"contract-status","inputFields":[{"name":"customerId","type":"String","description":"고객번호","example":"C123","required":true}],"outputFields":[{"name":"resultCode","type":"String","description":"결과 코드","example":"SUCCESS","required":true}]} {"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( MockMvc mockMvc = MockMvcBuilders.standaloneSetup(
new ScaffoldingController(builder, new ObjectMapper())) new ScaffoldingController(builder, new ObjectMapper()))
@@ -43,6 +43,14 @@ class ScaffoldingControllerToolDraftTest {
.content("{\"description\":\"고객번호로 계약 상태를 조회\"}")) .content("{\"description\":\"고객번호로 계약 상태를 조회\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.baseName").value("CustomerContractStatus")) .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")); .andExpect(jsonPath("$.inputFields[0].name").value("customerId"));
} }
} }

View File

@@ -38,6 +38,7 @@ dependencies {
// MCI XML 전문, JSON Tool Schema/Manifest 처리에 사용합니다. // MCI XML 전문, JSON Tool Schema/Manifest 처리에 사용합니다.
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1' api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1' api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:3.0.0' api 'com.networknt:json-schema-validator:3.0.0'

View File

@@ -10,6 +10,7 @@ public record ToolManifestItem(
String title, String title,
String description, String description,
Map<String, Object> inputSchema, Map<String, Object> inputSchema,
Map<String, Object> outputSchema,
ToolManifestAnnotations annotations, ToolManifestAnnotations annotations,
@JsonProperty("_meta") ToolManifestMeta meta) { @JsonProperty("_meta") ToolManifestMeta meta) {
} }

View File

@@ -1,5 +1,15 @@
package io.shinhanlife.dap.lib.manifest; package io.shinhanlife.dap.lib.manifest;
import java.util.List;
/** Operational metadata exposed by the Tool Service manifest. */ /** 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(); ? tool.getName() : tool.getDisplayName();
Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema(); Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema();
return new ToolManifestItem( 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()), new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())), isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"), new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(), 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) { private void validate(List<ToolManifestItem> tools) {
@@ -128,4 +130,8 @@ public class ToolManifestService {
private String defaultString(String value, String fallback) { private String defaultString(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value; 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) { private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
McpSchema.Tool mcpTool = McpSchema.Tool.builder() McpSchema.Tool mcpTool = ToolMetadataMcpMapper.toTool(tool);
.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();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool) return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build(); .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.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata; import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver; 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 jakarta.annotation.PostConstruct;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
@@ -29,14 +31,15 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.support.AopUtils; import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -49,7 +52,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@Component @Component
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@RequiredArgsConstructor
@ConditionalOnBean(McpToolExecutionService.class) @ConditionalOnBean(McpToolExecutionService.class)
public class ToolRegistryHeartbeatSender { public class ToolRegistryHeartbeatSender {
@@ -58,6 +60,23 @@ public class ToolRegistryHeartbeatSender {
private final McpProperties mcpProperties; private final McpProperties mcpProperties;
private final RestClient restClient = RestClient.create(); private final RestClient restClient = RestClient.create();
private final ToolSchemaResolver toolSchemaResolver; 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}") @Value("${axhub.gateway.url:http://localhost:8081}")
private String gatewayUrl; private String gatewayUrl;
@@ -143,11 +162,18 @@ public class ToolRegistryHeartbeatSender {
// TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction // TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType); Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType);
meta.setParametersSchema(finalSchema); meta.setParametersSchema(finalSchema);
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(
functionAnnotation, method.getReturnType(), hintAnnotation);
if (!outputSchema.isEmpty()) {
meta.setOutputSchema(outputSchema);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to generate schema for {}", subToolName, e); log.error("Failed to generate schema for {}", subToolName, e);
} }
} }
enrichWithDefinition(meta, rawSubToolName, hintAnnotation);
if (isRegister) { if (isRegister) {
registeredTools.add(meta); 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) @Scheduled(fixedRate = 30000)
public void sendHeartbeats() { public void sendHeartbeats() {
if (registeredTools.isEmpty()) return; 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 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 { public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in); Scanner scanner = new Scanner(System.in);
@@ -125,6 +136,17 @@ public class ToolScaffolder {
* Generates a Tool using an HTTP API name that is resolved from glow.communication.http.api-list. * 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 { 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); baseName = toPascalCase(baseName);
title = title == null || title.isBlank() ? baseName : title.trim(); title = title == null || title.isBlank() ? baseName : title.trim();
description = description == null ? "" : description.trim(); description = description == null ? "" : description.trim();
@@ -145,6 +167,7 @@ public class ToolScaffolder {
String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json"; String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json";
String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json"; String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json";
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-schemas", group.toLowerCase())); 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 inputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + inputSchemaFileName;
String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName; String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName;
@@ -878,11 +901,184 @@ public class ToolScaffolder {
Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName)); Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName));
log.append("[Unit Test] ").append(generatedTestPath).append("\\n"); 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("[Test Command] .\\gradlew.bat :").append(moduleName.substring(moduleName.lastIndexOf(java.io.File.separator) + 1)).append(":test --tests \"*").append(baseName).append("UseCaseTest\"\\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"); log.append("\n Tip: HTTP Tool은 WireMock 실행 후 생성된 mapping URL로 호출을 확인하세요.\n");
return log.toString(); 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) { private static String toKebabCase(String pascalCase) {
if (pascalCase == null || pascalCase.isEmpty()) return pascalCase; if (pascalCase == null || pascalCase.isEmpty()) return pascalCase;
return pascalCase return pascalCase

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 displayName; // 사람이 읽는 라벨 (1-128자)
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool) private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능) 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) // 2. 파라미터 스키마 (JSON Schema 형태의 Map)
private Map<String, Object> parametersSchema; private Map<String, Object> parametersSchema;
private Map<String, Object> outputSchema;
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨) // 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
private Map<String, String> actionPrompts; private Map<String, String> actionPrompts;

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 { private static class ValidatedRequest {
@McpToolParam(description = "recipient phone number", required = true) @McpToolParam(description = "recipient phone number", required = true)
@io.swagger.v3.oas.annotations.media.Schema(pattern = "^01[0-9]{8,9}$")
private String phoneNumber; private String phoneNumber;
@McpToolParam(description = "issue amount", required = true) @McpToolParam(description = "issue amount", required = true)
@io.swagger.v3.oas.annotations.media.Schema(minimum = "1")
private Long amount; private Long amount;
@McpToolParam(description = "approval result") @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; private String approvalStatus;
@McpToolParam(description = "page size") @McpToolParam(description = "page size")
@io.swagger.v3.oas.annotations.media.Schema(maximum = "50", defaultValue = "20")
private Integer pageSize; private Integer pageSize;
@McpToolParam(description = "reference") @McpToolParam(description = "reference")
@io.swagger.v3.oas.annotations.media.Schema(minLength = 1, maxLength = 30)
private String reference; private String reference;
} }
@@ -115,6 +122,9 @@ class JsonSchemaGeneratorTest {
} }
private static class NestedChild { 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; private String businessDate;
} }
} }

View File

@@ -1,5 +1,9 @@
package io.shinhanlife.dap.lib.util; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -12,6 +16,53 @@ import org.junit.jupiter.api.io.TempDir;
class ToolScaffolderTest { 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 @TempDir
Path root; Path root;

View File

@@ -35,6 +35,8 @@ class ToolManifestServiceTest {
assertTrue(item.annotations().readOnlyHint()); assertTrue(item.annotations().readOnlyHint());
assertEquals("1.2.0", item.meta().version()); assertEquals("1.2.0", item.meta().version());
assertEquals(3000, item.meta().timeoutMillis()); assertEquals(3000, item.meta().timeoutMillis());
assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"),
item.meta().exampleQueries());
} }
@Test @Test
@@ -86,6 +88,9 @@ class ToolManifestServiceTest {
.podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080") .podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080")
.displayName("계약 조회") .displayName("계약 조회")
.description("계약번호로 계약 정보를 조회합니다.") .description("계약번호로 계약 정보를 조회합니다.")
.exampleQueries(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"))
.tags(List.of("계약"))
.ownerOrg("MCP_TOOL")
.parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")), .parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")),
"required", List.of("contractNo"), "additionalProperties", false)) "required", List.of("contractNo"), "additionalProperties", false))
.semver(version) .semver(version)

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

@@ -20,7 +20,7 @@ import io.shinhanlife.dap.mcc.biz.cmm.dto.MetaCommonCodeRequest;
* </pre> * </pre>
*/ */
public interface MetaCommonCodeUseCase { 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") @ToolHint(register = false, requiresApproval = false, categoryKey = "cmm", mappingId = "CLCNNB00001")
Object execute(MetaCommonCodeRequest req); Object execute(MetaCommonCodeRequest req);
} }

View File

@@ -9,7 +9,7 @@ import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateRequest;
import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse; import io.shinhanlife.dap.mcc.biz.smp.dto.ExchangeRateResponse;
public interface ExchangeRateToolUseCase { 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") @ToolHint(register = false, categoryKey = "smp", mappingId = "EXCHANGE_001")
ExchangeRateResponse execute(ExchangeRateRequest req); ExchangeRateResponse execute(ExchangeRateRequest req);
} }

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

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

View File

@@ -0,0 +1,58 @@
# Scaffold AI V17 Metadata Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** AI Tool 초안 생성 시 Tool Schema V17 Metadata 전체를 생성하고 Scaffold 폼에 자동 입력한다.
**Architecture:** 기존 `/tool-draft` 응답 DTO를 확장하고 서버에서 값을 정규화한다. 화면은 서버 응답의 필드를 기존 V17 폼 요소에 직접 매핑하며, 실제 Scaffold 생성은 기존 `ToolDefinitionOptions` 경로를 그대로 사용한다.
**Tech Stack:** Java 21, Spring Boot MVC, Jackson, JUnit 5, MockMvc, HTML/JavaScript
## Global Constraints
- 기존 Tool 생성 경로와 Legacy 연동 정보 입력 정책을 유지한다.
- Tool Schema V17 필드명은 기존 `ToolDefinitionOptions`와 동일하게 유지한다.
- 기존 작업 트리 변경사항을 되돌리거나 포함 범위 밖에서 수정하지 않는다.
---
### Task 1: AI Tool Draft 서버 계약 확장
**Files:**
- Modify: `dap-gateway/src/test/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingControllerToolDraftTest.java`
- Modify: `dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/ScaffoldingController.java`
**Interfaces:**
- Consumes: AI가 반환한 JSON Tool 초안
- Produces: V17 Metadata가 포함된 `/api/v1/scaffold/tool-draft` JSON 응답
- [ ] Mock AI 응답과 API assertion에 V17 필드를 추가한다.
- [ ] 테스트를 실행해 현재 응답에서 V17 필드가 누락되어 실패하는지 확인한다.
- [ ] `ToolDraft`와 프롬프트 및 정규화 로직을 확장한다.
- [ ] 테스트가 통과하는지 확인한다.
### Task 2: Scaffold 화면 자동 매핑
**Files:**
- Modify: `dap-gateway/src/main/resources/static/admin/scaffold.html`
**Interfaces:**
- Consumes: Task 1의 V17 Metadata JSON 필드
- Produces: 같은 이름을 가진 V17 폼 입력값
- [ ] `createAiToolDraft()`에 문자열 필드 매핑을 추가한다.
- [ ] `exampleQueries`는 줄바꿈, `tags`는 쉼표 구분 문자열로 변환한다.
- [ ] 누락된 값은 빈 값 또는 `MCP_TOOL` 기본값으로 처리한다.
### Task 3: 통합 검증
**Files:**
- Verify: Gateway와 V17 관련 전체 변경
**Interfaces:**
- Consumes: Task 1~2 결과
- Produces: 컴파일 및 표준 검증 증거
- [ ] Gateway 대상 테스트를 실행한다.
- [ ] `validateToolSchemaV17`을 실행한다.
- [ ] `git diff --check`로 문법적 공백 오류를 확인한다.

View File

@@ -0,0 +1,162 @@
# Tool Schema V17 Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** BC-DAB-STD-003 V17의 Tool 스키마 필수 항목을 Tool Pod의 등록, Manifest, MCP 노출, Scaffold 및 빌드 검증 전 구간에 일관되게 적용한다.
**Architecture:** 각 Tool의 업무 명세는 `tool-definitions/{category}/{tool-name}.yml`에서 관리하고, 기동 시 공통 로더가 이를 읽어 기존 어노테이션 정보와 결합한다. 결합된 `ToolMetadata`를 단일 원천으로 Manifest와 MCP Tool을 생성해 Portal/Gateway/직접 MCP 연결 간 메타데이터 차이를 제거한다.
**Tech Stack:** Java 21, Spring Boot 3.5.11, Spring AI MCP 1.1.8, Jackson YAML/JSON, Gradle, JUnit 5, AssertJ
## Global Constraints
- Tool `name``^[a-z][a-z0-9_]{2,63}$`를 만족한다.
- V17 필수 14개 항목을 누락 없이 제공한다.
- `example_queries`는 3~10개이며 Tool 이름을 직접 포함하지 않는다.
- `parameters_schema`는 루트 `type: object`, `additionalProperties: false`이고 모든 property에 description을 둔다.
- `outputSchema`를 명시한 Tool만 출력 검증하며 기존 우선순위를 유지한다.
- 기존 SSE/Streamable HTTP 전송, trace-id/request-id/employee-id, MCI/HTTP 호출 흐름은 변경하지 않는다.
- 기존 미추적 WCM/HMCI 파일을 삭제하거나 덮어쓰지 않는다.
- `register=false`인 Tool을 임의로 운영 등록 상태로 변경하지 않는다.
---
### Task 1: V17 Tool Definition 모델과 로더
**Files:**
- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDescription.java`
- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinition.java`
- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepository.java`
- Create: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/metadata/ToolDefinitionRepositoryTest.java`
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/mcc/dto/ToolMetadata.java`
**Interfaces:**
- Produces: `Optional<ToolDefinition> findByName(String name)``ToolMetadata`의 V17 필드 접근자.
- Consumes: classpath `tool-definitions/**/*.yml`과 Jackson YAML.
- [ ] **Step 1: Write failing loader and validation tests**
빈 필수 설명, 2개 이하 예시 질의, 잘못된 name, object가 아닌 schema를 거부하고 정상 YAML을 로드하는 테스트를 작성한다.
- [ ] **Step 2: Run tests and verify RED**
Run: `./gradlew.bat :dap-was-lib:test --tests "*ToolDefinitionRepositoryTest"`
Expected: FAIL because V17 model/repository does not exist.
- [ ] **Step 3: Implement minimal immutable models and classpath loader**
`ToolDescription(function, whenToUse, whenNotToUse, ioLimits)`와 V17 전체 필드를 가진 `ToolDefinition`을 만들고 기동 시 한 번 로드·검증해 name 기준 불변 Map으로 캐시한다.
- [ ] **Step 4: Run tests and verify GREEN**
Run: `./gradlew.bat :dap-was-lib:test --tests "*ToolDefinitionRepositoryTest"`
Expected: PASS.
### Task 2: Registry 수집과 Manifest 표준 매핑
**Files:**
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSender.java`
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestItem.java`
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestMeta.java`
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/manifest/ToolManifestService.java`
- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolRegistryHeartbeatSenderTest.java`
- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/mcc/manifest/ToolManifestServiceTest.java`
**Interfaces:**
- Consumes: `ToolDefinitionRepository.findByName`.
- Produces: V17 description, display description, examples, owner/version/interface/environment metadata가 포함된 `ToolMetadata``/manifest` 응답.
- [ ] **Step 1: Add failing enrichment and manifest mapping tests**
- [ ] **Step 2: Run targeted tests and verify RED**
Run: `./gradlew.bat :dap-was-lib:test --tests "*ToolRegistryHeartbeatSenderTest" --tests "*ToolManifestServiceTest"`
- [ ] **Step 3: Merge annotation runtime data with definition YAML**
호출 주소·상태는 런타임 값, 업무 설명·예시·소유조직은 YAML 값을 사용하며 필수 정의가 없는 등록 대상 Tool은 기동 검증에서 실패시킨다.
- [ ] **Step 4: Map Manifest title/description/inputSchema/outputSchema/annotations/_meta**
- [ ] **Step 5: Run targeted tests and verify GREEN**
### Task 3: MCP Tool 직접 노출과 Gateway 동기화
**Files:**
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizer.java`
- Create or Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/mcp/ToolPodMcpToolSynchronizerTest.java`
- Modify: `dap-gateway/src/main/java/io/shinhanlife/dap/mcg/sync/RegistryMcpToolSpecificationFactory.java`
- Modify: `dap-gateway/src/test/java/io/shinhanlife/dap/biz/mcp/gateway/sync/RegistryMcpToolSpecificationFactoryTest.java`
**Interfaces:**
- Consumes: enriched `ToolMetadata`.
- Produces: 동일한 MCP `name`, `title`, 합성 description, input/output schema, readOnly/destructive/idempotent annotations, `_meta`.
- [ ] **Step 1: Add failing parity tests for Pod and Gateway MCP specs**
- [ ] **Step 2: Run both module tests and verify RED**
- [ ] **Step 3: Implement shared metadata-to-MCP mapping without changing transport**
- [ ] **Step 4: Run both module tests and verify GREEN**
### Task 4: 기존 Tool 전체 정의와 이름 정규화
**Files:**
- Create: `dap-was-sms/src/main/resources/tool-definitions/**/*.yml`
- Create: `dap-was-oth/src/main/resources/tool-definitions/**/*.yml`
- Modify: all `dap-was-sms/src/main/java/**/*UseCase*.java` containing `@McpTool`
- Modify: all `dap-was-oth/src/main/java/**/*UseCase*.java` containing `@McpTool`
- Modify: matching tests and mock fixture keys
**Interfaces:**
- Consumes: Task 1 YAML format.
- Produces: every discovered Tool has one unique V17 definition.
- [ ] **Step 1: Add failing repository-wide compliance test**
모든 `@McpTool` name에 정확히 하나의 정의가 있고 필수 필드/예시 수/name 규칙을 만족하는지 검사한다.
- [ ] **Step 2: Run compliance test and verify RED**
- [ ] **Step 3: Rename invalid names and add complete definitions**
`smp_exchangeRate_inquiry``smp_exchange_inquiry`, `cmm_commonCode_lookup``cmm_comcode_lookup`로 바꾸고 나머지 호출명은 호왘성을 위해 유지한다.
- [ ] **Step 4: Update tests/fixtures and verify GREEN**
### Task 5: Scaffold V17 생성 지원
**Files:**
- Modify: `dap-gateway/src/main/resources/static/admin/scaffold.html`
- Modify: Scaffold request DTO/controller files discovered under `dap-gateway/src/main/java`
- Modify: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/util/ToolScaffolder.java`
- Modify: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/util/ToolScaffolderTest.java`
**Interfaces:**
- Consumes: 입력한 function/when-to-use/when-not-to-use/io-limits/display-description/examples/owner/tags/hints.
- Produces: Java 소스, schemas, V17 tool-definition YAML.
- [ ] **Step 1: Add failing scaffold generation tests**
- [ ] **Step 2: Verify RED**
- [ ] **Step 3: Add form fields, validation, preview and YAML generation**
- [ ] **Step 4: Verify GREEN including duplicate DTO-field regression tests**
### Task 6: Gradle 품질 게이트와 문서
**Files:**
- Create: `dap-was-lib/src/main/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunner.java`
- Create: `dap-was-lib/src/test/java/io/shinhanlife/dap/lib/validation/ToolSchemaV17ValidationRunnerTest.java`
- Modify: `build.gradle`
- Modify: `README.md`
**Interfaces:**
- Produces: `validateToolSchemaV17` Gradle task; `bootJar` before validation; file/Tool/field가 표시되는 실패 메시지.
- [ ] **Step 1: Add failing validation runner tests**
- [ ] **Step 2: Verify RED**
- [ ] **Step 3: Implement runner and wire `bootJar.dependsOn(validateToolSchemaV17)`**
- [ ] **Step 4: Document V17 fields, examples, Scaffold, compatibility and commands**
- [ ] **Step 5: Run targeted and full verification**
Run:
- `./gradlew.bat :dap-was-lib:test`
- `./gradlew.bat :dap-was-sms:test :dap-was-oth:test :dap-gateway:test`
- `./gradlew.bat validateMcpToolNames validateToolSchemaV17`
- `./gradlew.bat clean build`
Expected: all tasks succeed with zero test failures.

View File

@@ -0,0 +1,22 @@
# Scaffold AI V17 Metadata Design
## Goal
`AI로 Tool 채우기` 실행 시 Tool 기본 정보와 입출력 필드뿐 아니라 Tool Schema V17 Metadata도 함께 생성하고 화면에 반영한다.
## Design
- `/api/v1/scaffold/tool-draft`의 AI JSON 계약에 `functionDescription`, `displayDescription`, `whenToUse`, `whenNotToUse`, `ioLimits`, `exampleQueries`, `tags`, `ownerOrg`를 추가한다.
- 서버는 문자열을 trim하고 목록 필드는 비어 있는 항목을 제거하여 반환한다.
- `scaffold.html`은 응답받은 V17 값을 같은 이름의 폼 필드에 채운다. 목록은 화면의 기존 입력 규칙에 맞게 줄바꿈 또는 쉼표 문자열로 변환한다.
- Legacy Interface ID와 Client System Code는 기존 방침대로 AI가 생성하지 않는다.
## Error Handling
- V17 필드가 누락된 과거 형식의 AI 응답도 역직렬화할 수 있게 하되, 서버에서 업무 설명을 기반으로 안전한 기본값을 생성한다.
- AI가 빈 배열 또는 공백 항목을 반환하면 기본 예시 질의, 카테고리 태그, 기본 담당 조직을 적용한다.
## Verification
- Mock AI 응답을 사용하는 MVC 테스트로 모든 V17 필드가 API 응답에 포함되는지 검증한다.
- Gateway 테스트와 Tool Schema V17 검증을 실행한다.

View File

@@ -88,8 +88,8 @@ Manifest Tool 항목에는 `outputSchema`를 추가한다. `_meta`에는 `versio
모든 이름을 영문 소문자 snake_case 3~64자로 통일한다. 현재 확인된 변경 대상은 다음과 같다. 모든 이름을 영문 소문자 snake_case 3~64자로 통일한다. 현재 확인된 변경 대상은 다음과 같다.
- `smp_exchangeRate_inquiry` -> `smp_exchange_rate_inquiry` - `smp_exchangeRate_inquiry` -> `smp_exchange_inquiry`
- `cmm_commonCode_lookup` -> `cmm_common_code_lookup` - `cmm_commonCode_lookup` -> `cmm_comcode_lookup`
나머지 Tool도 같은 정규식과 `category_service_action` 의미 구조로 검증한다. MCI/EIMS 인터페이스 ID는 Tool명으로 사용하지 않고 `legacy_interface_id`에 저장한다. 나머지 Tool도 같은 정규식과 `category_service_action` 의미 구조로 검증한다. MCI/EIMS 인터페이스 ID는 Tool명으로 사용하지 않고 `legacy_interface_id`에 저장한다.