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

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

View File

@@ -1,5 +1,15 @@
package io.shinhanlife.dap.lib.manifest;
import java.util.List;
/** Operational metadata exposed by the Tool Service manifest. */
public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) {
}
public record ToolManifestMeta(
String version,
long timeoutMillis,
boolean enabled,
List<String> exampleQueries,
List<String> tags,
String legacyInterfaceId,
List<String> requiredEnvKeys,
String ownerOrg) {
}

View File

@@ -61,12 +61,14 @@ public class ToolManifestService {
? tool.getName() : tool.getDisplayName();
Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema();
return new ToolManifestItem(
tool.getName(), endpoint(tool), title, tool.getDescription(), schema,
tool.getName(), endpoint(tool), title, tool.getDescription(), schema, tool.getOutputSchema(),
new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(),
tool.getEnabled() == null || tool.getEnabled()));
tool.getEnabled() == null || tool.getEnabled(),
defaultList(tool.getExampleQueries()), defaultList(tool.getTags()),
tool.getMciServiceId(), defaultList(tool.getRequiredEnvKeys()), tool.getOwnerOrg()));
}
private void validate(List<ToolManifestItem> tools) {
@@ -128,4 +130,8 @@ public class ToolManifestService {
private String defaultString(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
}
private List<String> defaultList(List<String> value) {
return value == null ? List.of() : List.copyOf(value);
}
}

View File

@@ -0,0 +1,73 @@
package io.shinhanlife.dap.lib.mcp;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Registry 메타데이터를 MCP SDK Tool 명세로 일관되게 변환합니다. */
public final class ToolMetadataMcpMapper {
private ToolMetadataMcpMapper() {
}
public static McpSchema.Tool toTool(ToolMetadata metadata) {
McpSchema.Tool.Builder builder = McpSchema.Tool.builder()
.name(metadata.getName())
.title(defaultText(metadata.getDisplayName(), metadata.getName()))
.description(defaultText(metadata.getDescription(), metadata.getName() + " Tool"))
.inputSchema(toJsonSchema(metadata.getParametersSchema()))
.annotations(new McpSchema.ToolAnnotations(
metadata.getDisplayName(), metadata.getReadOnlyHint(), metadata.getDestructiveHint(),
metadata.getIdempotentHint(), metadata.getOpenWorldHint(), null))
.meta(meta(metadata));
if (metadata.getOutputSchema() != null && !metadata.getOutputSchema().isEmpty()) {
builder.outputSchema(metadata.getOutputSchema());
}
return builder.build();
}
public static Map<String, Object> meta(ToolMetadata metadata) {
Map<String, Object> meta = new LinkedHashMap<>();
put(meta, "version", metadata.getSemver());
put(meta, "category_key", metadata.getCategoryKey());
put(meta, "display_description", metadata.getDisplayDescription());
put(meta, "example_queries", metadata.getExampleQueries());
put(meta, "tags", metadata.getTags());
put(meta, "legacy_interface_id", metadata.getMciServiceId());
put(meta, "required_env_keys", metadata.getRequiredEnvKeys());
put(meta, "owner_org", metadata.getOwnerOrg());
return Map.copyOf(meta);
}
private static void put(Map<String, Object> target, String key, Object value) {
if (value instanceof String text && !text.isBlank()) {
target.put(key, text);
} else if (value instanceof List<?> list && !list.isEmpty()) {
target.put(key, List.copyOf(list));
}
}
private static String defaultText(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
@SuppressWarnings("unchecked")
private static McpSchema.JsonSchema toJsonSchema(Map<String, Object> source) {
Map<String, Object> schema = source == null ? emptySchema() : source;
return new McpSchema.JsonSchema(
String.valueOf(schema.getOrDefault("type", "object")),
schema.get("properties") instanceof Map<?, ?> properties
? (Map<String, Object>) properties : Map.of(),
schema.get("required") instanceof List<?> required ? (List<String>) required : List.of(),
schema.get("additionalProperties") instanceof Boolean additionalProperties
? additionalProperties : Boolean.TRUE,
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
schema.get("definitions") instanceof Map<?, ?> definitions
? (Map<String, Object>) definitions : Map.of());
}
private static Map<String, Object> emptySchema() {
return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false);
}
}

View File

@@ -39,18 +39,7 @@ public class ToolPodMcpToolSynchronizer {
}
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(toJsonSchema(tool.getParametersSchema()))
.annotations(new McpSchema.ToolAnnotations(
tool.getDisplayName(),
tool.getReadOnlyHint(),
tool.getDestructiveHint(),
tool.getIdempotentHint(),
tool.getOpenWorldHint(),
null))
.build();
McpSchema.Tool mcpTool = ToolMetadataMcpMapper.toTool(tool);
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build();
}

View File

@@ -21,6 +21,8 @@ import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository;
import jakarta.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.ArrayList;
@@ -29,14 +31,15 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -49,7 +52,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@Component
@Configuration
@EnableScheduling
@RequiredArgsConstructor
@ConditionalOnBean(McpToolExecutionService.class)
public class ToolRegistryHeartbeatSender {
@@ -58,6 +60,23 @@ public class ToolRegistryHeartbeatSender {
private final McpProperties mcpProperties;
private final RestClient restClient = RestClient.create();
private final ToolSchemaResolver toolSchemaResolver;
private final ToolDefinitionRepository toolDefinitionRepository;
@Autowired
public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper,
McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver,
@Nullable ToolDefinitionRepository toolDefinitionRepository) {
this.applicationContext = applicationContext;
this.objectMapper = objectMapper;
this.mcpProperties = mcpProperties;
this.toolSchemaResolver = toolSchemaResolver;
this.toolDefinitionRepository = toolDefinitionRepository;
}
public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper,
McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver) {
this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null);
}
@Value("${axhub.gateway.url:http://localhost:8081}")
private String gatewayUrl;
@@ -143,11 +162,18 @@ public class ToolRegistryHeartbeatSender {
// TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType);
meta.setParametersSchema(finalSchema);
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(
functionAnnotation, method.getReturnType(), hintAnnotation);
if (!outputSchema.isEmpty()) {
meta.setOutputSchema(outputSchema);
}
} catch (Exception e) {
log.error("Failed to generate schema for {}", subToolName, e);
}
}
enrichWithDefinition(meta, rawSubToolName, hintAnnotation);
if (isRegister) {
registeredTools.add(meta);
}
@@ -158,6 +184,45 @@ public class ToolRegistryHeartbeatSender {
}
}
private void enrichWithDefinition(ToolMetadata meta, String rawToolName, ToolHint hintAnnotation) {
if (toolDefinitionRepository == null) {
return;
}
toolDefinitionRepository.findByName(rawToolName)
.ifPresent(definition -> applyDefinition(meta, definition, hintAnnotation));
}
private void applyDefinition(ToolMetadata meta, ToolDefinition definition, ToolHint hintAnnotation) {
meta.setDisplayName(definition.displayName());
meta.setSemver(definition.version());
meta.setCategoryKey(definition.categoryKey());
meta.setFunctionDescription(definition.description().function());
meta.setWhenToUse(definition.description().whenToUse());
meta.setWhenNotToUse(definition.description().whenNotToUse());
meta.setIoLimits(definition.description().ioLimits());
meta.setDescription(String.join("\n", definition.description().function(),
"사용 시점: " + definition.description().whenToUse(),
"사용 제외: " + definition.description().whenNotToUse(),
"입출력 제한: " + definition.description().ioLimits()));
meta.setDisplayDescription(definition.displayDescription());
meta.setExampleQueries(definition.exampleQueries());
meta.setReadOnlyHint(definition.readOnly());
meta.setDestructiveHint(definition.destructive());
meta.setIdempotentHint(definition.idempotent());
boolean explicitInputResource = hintAnnotation != null && !hintAnnotation.inputSchemaResource().isBlank();
boolean explicitOutputResource = hintAnnotation != null && !hintAnnotation.outputSchemaResource().isBlank();
if (!explicitInputResource) {
meta.setParametersSchema(definition.parametersSchema());
}
if (!explicitOutputResource && definition.outputSchema() != null && !definition.outputSchema().isEmpty()) {
meta.setOutputSchema(definition.outputSchema());
}
meta.setTags(definition.tags());
meta.setMciServiceId(definition.legacyInterfaceId());
meta.setRequiredEnvKeys(definition.requiredEnvKeys());
meta.setOwnerOrg(definition.ownerOrg());
}
@Scheduled(fixedRate = 30000)
public void sendHeartbeats() {
if (registeredTools.isEmpty()) return;

View File

@@ -0,0 +1,37 @@
package io.shinhanlife.dap.lib.metadata;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
/** BC-DAB-STD-003 V17 Tool 정의 파일의 불변 모델입니다. */
public record ToolDefinition(
String name,
@JsonProperty("display_name") String displayName,
String version,
@JsonProperty("category_key") String categoryKey,
ToolDescription description,
@JsonProperty("display_description") String displayDescription,
@JsonProperty("example_queries") List<String> exampleQueries,
@JsonProperty("read_only") Boolean readOnly,
Boolean destructive,
Boolean idempotent,
@JsonProperty("parameters_schema") Map<String, Object> parametersSchema,
@JsonProperty("output_schema") Map<String, Object> outputSchema,
List<String> tags,
@JsonProperty("legacy_interface_id") String legacyInterfaceId,
@JsonProperty("required_env_keys") List<String> requiredEnvKeys,
@JsonProperty("owner_org") String ownerOrg) {
public ToolDefinition withExampleQueries(List<String> queries) {
return new ToolDefinition(name, displayName, version, categoryKey, description, displayDescription,
queries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId,
requiredEnvKeys, ownerOrg);
}
public ToolDefinition withName(String value) {
return new ToolDefinition(value, displayName, version, categoryKey, description, displayDescription,
exampleQueries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId,
requiredEnvKeys, ownerOrg);
}
}

View File

@@ -0,0 +1,62 @@
package io.shinhanlife.dap.lib.metadata;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.stereotype.Component;
/** classpath의 Tool 정의를 기동 시 한 번 읽어 name 기준으로 캐시합니다. */
@Component
public class ToolDefinitionRepository {
public static final String DEFAULT_LOCATION = "classpath*:tool-definitions/**/*.yml";
private final Map<String, ToolDefinition> definitions;
@Autowired
public ToolDefinitionRepository(ResourceLoader resourceLoader) {
this(new ObjectMapper(new YAMLFactory()), resourceLoader, DEFAULT_LOCATION);
}
public ToolDefinitionRepository(ObjectMapper yamlMapper, ResourceLoader resourceLoader, String location) {
this(yamlMapper, new PathMatchingResourcePatternResolver(resourceLoader), location);
}
private ToolDefinitionRepository(ObjectMapper yamlMapper, ResourcePatternResolver resolver, String location) {
this.definitions = Collections.unmodifiableMap(load(yamlMapper, resolver, location));
}
public Optional<ToolDefinition> findByName(String name) {
return Optional.ofNullable(definitions.get(name));
}
public Map<String, ToolDefinition> findAll() {
return definitions;
}
private Map<String, ToolDefinition> load(ObjectMapper mapper, ResourcePatternResolver resolver, String location) {
Map<String, ToolDefinition> loaded = new LinkedHashMap<>();
try {
for (Resource resource : resolver.getResources(location)) {
ToolDefinition definition = mapper.readValue(resource.getInputStream(), ToolDefinition.class);
String source = resource.getDescription();
ToolDefinitionValidator.validate(definition, source);
ToolDefinition previous = loaded.putIfAbsent(definition.name(), definition);
if (previous != null) {
throw new IllegalStateException("Duplicate Tool definition name: " + definition.name());
}
}
return loaded;
} catch (IOException exception) {
throw new IllegalStateException("Failed to load Tool definitions from " + location, exception);
}
}
}

View File

@@ -0,0 +1,84 @@
package io.shinhanlife.dap.lib.metadata;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/** Tool 정의가 BC-DAB-STD-003 V17 필수 규칙을 만족하는지 검증합니다. */
public final class ToolDefinitionValidator {
private static final Pattern TOOL_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,63}$");
private ToolDefinitionValidator() {
}
public static void validate(ToolDefinition definition, String source) {
if (definition == null) {
fail(source, "definition", "문서가 비어 있습니다");
}
if (isBlank(definition.name()) || !TOOL_NAME.matcher(definition.name()).matches()) {
fail(source, "name", "^[a-z][a-z0-9_]{2,63}$ 형식이어야 합니다");
}
requireText(source, "display_name", definition.displayName());
requireText(source, "version", definition.version());
requireText(source, "category_key", definition.categoryKey());
requireText(source, "display_description", definition.displayDescription());
if (definition.description() == null) {
fail(source, "description", "필수입니다");
}
requireText(source, "description.function", definition.description().function());
requireText(source, "description.when_to_use", definition.description().whenToUse());
requireText(source, "description.when_not_to_use", definition.description().whenNotToUse());
requireText(source, "description.io_limits", definition.description().ioLimits());
List<String> examples = definition.exampleQueries();
if (examples == null || examples.size() < 3 || examples.size() > 10
|| examples.stream().anyMatch(ToolDefinitionValidator::isBlank)) {
fail(source, "example_queries", "비어 있지 않은 자연어 질의가 3~10개 필요합니다");
}
if (examples.stream().anyMatch(query -> query.contains(definition.name()))) {
fail(source, "example_queries", "Tool name을 직접 포함할 수 없습니다");
}
if (definition.readOnly() == null || definition.destructive() == null || definition.idempotent() == null) {
fail(source, "annotations", "read_only, destructive, idempotent는 필수입니다");
}
validateSchema(definition.parametersSchema(), source);
if (definition.outputSchema() != null && !definition.outputSchema().isEmpty()) {
validateSchema(definition.outputSchema(), source + " output_schema");
}
}
@SuppressWarnings("unchecked")
private static void validateSchema(Map<String, Object> schema, String source) {
if (schema == null || !"object".equals(schema.get("type"))) {
fail(source, "parameters_schema.type", "object여야 합니다");
}
if (!Boolean.FALSE.equals(schema.get("additionalProperties"))) {
fail(source, "parameters_schema.additionalProperties", "false여야 합니다");
}
Object propertiesValue = schema.get("properties");
if (!(propertiesValue instanceof Map<?, ?>)) {
fail(source, "parameters_schema.properties", "object여야 합니다");
}
Map<?, ?> properties = (Map<?, ?>) propertiesValue;
for (Map.Entry<?, ?> entry : properties.entrySet()) {
if (!(entry.getValue() instanceof Map<?, ?> property)
|| isBlank(String.valueOf(property.containsKey("description")
? property.get("description") : ""))) {
fail(source, "parameters_schema.properties." + entry.getKey() + ".description", "필수입니다");
}
}
}
private static void requireText(String source, String field, String value) {
if (isBlank(value)) {
fail(source, field, "필수입니다");
}
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static void fail(String source, String field, String message) {
throw new IllegalStateException("Invalid Tool definition [" + source + "] " + field + ": " + message);
}
}

View File

@@ -0,0 +1,11 @@
package io.shinhanlife.dap.lib.metadata;
import com.fasterxml.jackson.annotation.JsonProperty;
/** LLM이 Tool 선택 여부를 판단할 때 사용하는 V17 목적 설명입니다. */
public record ToolDescription(
String function,
@JsonProperty("when_to_use") String whenToUse,
@JsonProperty("when_not_to_use") String whenNotToUse,
@JsonProperty("io_limits") String ioLimits) {
}

View File

@@ -47,6 +47,17 @@ public class ToolScaffolder {
public record FieldDefinition(String name, String type, String description, String example, boolean required) {
}
public record ToolDefinitionOptions(
String functionDescription,
String whenToUse,
String whenNotToUse,
String ioLimits,
String displayDescription,
List<String> exampleQueries,
List<String> tags,
String ownerOrg) {
}
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
@@ -125,6 +136,17 @@ public class ToolScaffolder {
* Generates a Tool using an HTTP API name that is resolved from glow.communication.http.api-list.
*/
public static String scaffold(String baseName, String interfaceId, String title, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List<FieldDefinition> inputFields, List<FieldDefinition> outputFields, String httpApiName) throws IOException {
return scaffold(baseName, interfaceId, title, description, group, routingType, moduleName, author, createDate,
register, clientSystemCode, inputSchemaResource, outputSchemaResource, inputFields, outputFields,
httpApiName, null);
}
public static String scaffold(String baseName, String interfaceId, String title, String description, String group,
String routingType, String moduleName, String author, String createDate,
boolean register, String clientSystemCode, String inputSchemaResource,
String outputSchemaResource, List<FieldDefinition> inputFields,
List<FieldDefinition> outputFields, String httpApiName,
ToolDefinitionOptions definitionOptions) throws IOException {
baseName = toPascalCase(baseName);
title = title == null || title.isBlank() ? baseName : title.trim();
description = description == null ? "" : description.trim();
@@ -145,6 +167,7 @@ public class ToolScaffolder {
String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json";
String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json";
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-schemas", group.toLowerCase()));
Path definitionDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-definitions", group.toLowerCase()));
String inputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + inputSchemaFileName;
String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName;
@@ -878,11 +901,184 @@ public class ToolScaffolder {
Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName));
log.append("[Unit Test] ").append(generatedTestPath).append("\\n");
log.append("[Test Command] .\\gradlew.bat :").append(moduleName.substring(moduleName.lastIndexOf(java.io.File.separator) + 1)).append(":test --tests \"*").append(baseName).append("UseCaseTest\"\\n");
Files.createDirectories(definitionDir);
Path definitionPath = definitionDir.resolve(toolName + ".yml");
Files.writeString(definitionPath, toolDefinitionContentV17(toolName, title, description, group,
interfaceId, inputFields, isMutationTool(baseName), definitionOptions));
log.append("[V17 Tool Definition] ").append(definitionPath).append("\n");
log.append("\n Tip: HTTP Tool은 WireMock 실행 후 생성된 mapping URL로 호출을 확인하세요.\n");
return log.toString();
}
private static String toolDefinitionContent(String toolName, String title, String description,
String categoryKey, String interfaceId,
List<FieldDefinition> inputFields, boolean mutation) {
String safeDescription = description == null || description.isBlank()
? title + " 기능을 수행한다." : description;
StringBuilder properties = new StringBuilder();
StringBuilder required = new StringBuilder();
Set<String> generatedNames = new LinkedHashSet<>();
for (FieldDefinition field : inputFields == null ? List.<FieldDefinition>of() : inputFields) {
if (field == null || field.name() == null || field.name().isBlank()
|| !generatedNames.add(field.name().trim())) {
continue;
}
properties.append(" ").append(field.name()).append(":\n")
.append(" type: ").append(jsonSchemaType(field.type())).append("\n")
.append(" description: ").append(yamlText(field.description())).append("\n");
if (field.required()) {
required.append(" - ").append(field.name()).append("\n");
}
}
if (properties.isEmpty()) {
properties.append(" {}\n");
}
String requiredBlock = required.isEmpty() ? "" : " required:\n" + required;
String legacyLine = interfaceId == null || interfaceId.isBlank()
? "" : "legacy_interface_id: " + yamlText(interfaceId) + "\n";
return """
name: %s
display_name: %s
version: 1.0.0
category_key: %s
description:
function: %s
when_to_use: 사용자가 이 업무 기능의 실행 또는 조회를 명확히 요청한 경우 사용한다.
when_not_to_use: 입력값이 확인되지 않았거나 다른 업무 기능이 더 적합한 경우에는 사용하지 않는다.
io_limits: 정의된 입력 항목만 허용하며 응답 DTO에 정의된 업무 결과만 반환한다.
display_description: %s
example_queries:
- %s 처리해줘
- %s 정보를 확인해줘
- %s 업무 결과를 알려줘
read_only: %s
destructive: %s
idempotent: %s
parameters_schema:
type: object
properties:
%s%s additionalProperties: false
tags: [%s]
%srequired_env_keys: []
owner_org: MCP_TOOL
""".formatted(toolName, yamlText(title), categoryKey.toLowerCase(Locale.ROOT),
yamlText(safeDescription), yamlText(title), yamlText(title), yamlText(title), yamlText(title),
!mutation, mutation, !mutation, properties, requiredBlock,
categoryKey.toLowerCase(Locale.ROOT), legacyLine);
}
private static String toolDefinitionContentV17(String toolName, String title, String description,
String categoryKey, String interfaceId,
List<FieldDefinition> inputFields, boolean mutation,
ToolDefinitionOptions options) {
String function = option(options == null ? null : options.functionDescription(),
option(description, title + " 기능을 수행한다."));
String whenToUse = option(options == null ? null : options.whenToUse(),
"사용자가 해당 업무 기능의 실행 또는 조회를 명확히 요청한 경우 사용한다.");
String whenNotToUse = option(options == null ? null : options.whenNotToUse(),
"필수 입력값이 확인되지 않았거나 다른 업무 기능이 더 적합한 경우에는 사용하지 않는다.");
String ioLimits = option(options == null ? null : options.ioLimits(),
"정의된 입력 항목만 허용하며 응답 DTO에 정의된 업무 결과만 반환한다.");
String displayDescription = option(options == null ? null : options.displayDescription(), title);
List<String> examples = normalizedList(options == null ? null : options.exampleQueries(), List.of(
title + " 처리해줘", title + " 정보를 확인해줘", title + " 업무 결과를 알려줘"));
List<String> tags = normalizedList(options == null ? null : options.tags(),
List.of(categoryKey.toLowerCase(Locale.ROOT)));
String ownerOrg = option(options == null ? null : options.ownerOrg(), "MCP_TOOL");
StringBuilder properties = new StringBuilder();
StringBuilder required = new StringBuilder();
Set<String> generatedNames = new LinkedHashSet<>();
for (FieldDefinition field : inputFields == null ? List.<FieldDefinition>of() : inputFields) {
if (field == null || field.name() == null || field.name().isBlank()
|| !generatedNames.add(field.name().trim())) {
continue;
}
properties.append(" ").append(field.name().trim()).append(":\n")
.append(" type: ").append(jsonSchemaType(field.type())).append("\n")
.append(" description: ").append(yamlText(field.description())).append("\n");
if (field.required()) {
required.append(" - ").append(field.name().trim()).append("\n");
}
}
if (properties.isEmpty()) {
properties.append(" {}\n");
}
String requiredBlock = required.isEmpty() ? "" : " required:\n" + required;
String legacyLine = interfaceId == null || interfaceId.isBlank()
? "" : "legacy_interface_id: " + yamlText(interfaceId) + "\n";
String exampleBlock = examples.stream().map(value -> " - " + yamlText(value))
.collect(java.util.stream.Collectors.joining("\n"));
String tagBlock = tags.stream().map(ToolScaffolder::yamlText)
.collect(java.util.stream.Collectors.joining(", "));
return """
name: %s
display_name: %s
version: 1.0.0
category_key: %s
description:
function: %s
when_to_use: %s
when_not_to_use: %s
io_limits: %s
display_description: %s
example_queries:
%s
read_only: %s
destructive: %s
idempotent: %s
parameters_schema:
type: object
properties:
%s%s additionalProperties: false
tags: [%s]
%srequired_env_keys: []
owner_org: %s
""".formatted(toolName, yamlText(title), categoryKey.toLowerCase(Locale.ROOT),
yamlText(function), yamlText(whenToUse), yamlText(whenNotToUse), yamlText(ioLimits),
yamlText(displayDescription), exampleBlock, !mutation, mutation, !mutation,
properties, requiredBlock, tagBlock, legacyLine, yamlText(ownerOrg));
}
private static String option(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
private static List<String> normalizedList(List<String> values, List<String> fallback) {
if (values == null) {
return fallback;
}
List<String> normalized = values.stream()
.filter(java.util.Objects::nonNull)
.map(String::trim)
.filter(value -> !value.isBlank())
.distinct()
.toList();
return normalized.isEmpty() ? fallback : normalized;
}
private static boolean isMutationTool(String baseName) {
String value = baseName.toLowerCase(Locale.ROOT);
return value.matches(".*(create|add|update|delete|remove|send|process|approve|reject|register|issue).*");
}
private static String jsonSchemaType(String javaType) {
return switch (javaType == null ? "String" : javaType) {
case "Integer", "Long" -> "integer";
case "Double", "BigDecimal" -> "number";
case "Boolean" -> "boolean";
default -> "string";
};
}
private static String yamlText(String value) {
String safe = value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\r", " ").replace("\n", " ");
return "\"" + safe + "\"";
}
private static String toKebabCase(String pascalCase) {
if (pascalCase == null || pascalCase.isEmpty()) return pascalCase;
return pascalCase

View File

@@ -0,0 +1,78 @@
package io.shinhanlife.dap.lib.validation;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
/** CI/CD에서 모든 Java MCP Tool과 V17 정의 파일의 1:1 대응을 검증합니다. */
public final class ToolSchemaV17ValidationRunner {
private static final Pattern MCP_TOOL_NAME = Pattern.compile(
"@McpTool\\s*\\(\\s*name\\s*=\\s*\"([^\"]+)\"");
private ToolSchemaV17ValidationRunner() {
}
public static void main(String[] args) {
if (args.length != 1) {
throw new IllegalArgumentException("Usage: ToolSchemaV17ValidationRunner <project-root>");
}
validate(Path.of(args[0]));
}
static void validate(Path projectRoot) {
ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
Map<String, Path> definitions = new LinkedHashMap<>();
Set<String> toolNames = new LinkedHashSet<>();
try (Stream<Path> files = Files.walk(projectRoot)) {
for (Path file : files.filter(Files::isRegularFile).toList()) {
String normalized = file.toString().replace('\\', '/');
if (normalized.contains("/build/") || normalized.contains("/.gradle/")
|| normalized.contains("/tmp_") || normalized.contains("/org/")) {
continue;
}
if (normalized.endsWith(".java") && normalized.contains("/dap-was-")
&& !normalized.contains("/dap-was-lib/")) {
Matcher matcher = MCP_TOOL_NAME.matcher(Files.readString(file, StandardCharsets.UTF_8));
while (matcher.find()) {
toolNames.add(matcher.group(1));
}
}
if (normalized.contains("/src/main/resources/tool-definitions/")
&& (normalized.endsWith(".yml") || normalized.endsWith(".yaml"))) {
ToolDefinition definition = yamlMapper.readValue(file.toFile(), ToolDefinition.class);
ToolDefinitionValidator.validate(definition, file.toString());
Path previous = definitions.putIfAbsent(definition.name(), file);
if (previous != null) {
throw new IllegalStateException("Duplicate V17 Tool definition: " + definition.name()
+ " [" + previous + ", " + file + "]");
}
}
}
} catch (IOException exception) {
throw new IllegalStateException("Failed to scan Tool schema V17 files", exception);
}
Set<String> missing = new LinkedHashSet<>(toolNames);
missing.removeAll(definitions.keySet());
if (!missing.isEmpty()) {
throw new IllegalStateException("Missing V17 Tool definitions: " + missing);
}
Set<String> orphan = new LinkedHashSet<>(definitions.keySet());
orphan.removeAll(toolNames);
if (!orphan.isEmpty()) {
throw new IllegalStateException("V17 definitions without matching @McpTool: " + orphan);
}
System.out.println("Tool schema V17 validation passed: " + toolNames.size() + " tools");
}
}

View File

@@ -44,9 +44,19 @@ public class ToolMetadata {
private String displayName; // 사람이 읽는 라벨 (1-128자)
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
private String functionDescription;
private String whenToUse;
private String whenNotToUse;
private String ioLimits;
private String displayDescription;
private List<String> exampleQueries;
private List<String> tags;
private String ownerOrg;
private List<String> requiredEnvKeys;
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
private Map<String, Object> parametersSchema;
private Map<String, Object> outputSchema;
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
private Map<String, String> actionPrompts;

View File

@@ -0,0 +1,73 @@
package io.shinhanlife.dap.lib.metadata;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.io.DefaultResourceLoader;
class ToolDefinitionRepositoryTest {
@Test
void springCanCreateRepositoryWithoutDefaultConstructor() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.register(ToolDefinitionRepository.class);
context.refresh();
assertTrue(context.getBean(ToolDefinitionRepository.class)
.findByName("cmm_claim_search").isPresent());
}
}
@Test
void loadsAndCachesAValidV17DefinitionByToolName() {
ToolDefinitionRepository repository = new ToolDefinitionRepository(
new ObjectMapper(new YAMLFactory()), new DefaultResourceLoader(),
"classpath*:tool-definitions/**/*.yml");
ToolDefinition definition = repository.findByName("cmm_claim_search").orElseThrow();
assertEquals("보험금 청구 상태 조회", definition.displayName());
assertEquals("cmm", definition.categoryKey());
assertEquals(3, definition.exampleQueries().size());
assertEquals(false, definition.parametersSchema().get("additionalProperties"));
}
@Test
void rejectsDefinitionWithFewerThanThreeExampleQueries() {
ToolDefinition invalid = validDefinition().withExampleQueries(java.util.List.of("청구 상태 알려줘"));
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> ToolDefinitionValidator.validate(invalid, "memory:invalid"));
assertTrue(error.getMessage().contains("example_queries"));
}
@Test
void rejectsNonStandardToolName() {
ToolDefinition invalid = validDefinition().withName("cmm_claim.Search");
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> ToolDefinitionValidator.validate(invalid, "memory:invalid"));
assertTrue(error.getMessage().contains("name"));
}
private ToolDefinition validDefinition() {
return new ToolDefinition(
"cmm_claim_search", "보험금 청구 상태 조회", "1.0.0", "cmm",
new ToolDescription("청구 상태를 조회한다.", "상태 확인 시 사용한다.",
"청구 접수 시 사용하지 않는다.", "청구번호가 필요하다."),
"보험금 청구 상태를 조회합니다.",
java.util.List.of("청구 상태 알려줘", "심사 결과 조회해줘", "계약번호로 청구를 찾아줘"),
true, false, true,
java.util.Map.of("type", "object", "properties", java.util.Map.of(),
"additionalProperties", false),
null,
java.util.List.of("보험금"), null, java.util.List.of(), "MCP_TOOL");
}
}

View File

@@ -91,18 +91,25 @@ class JsonSchemaGeneratorTest {
private static class ValidatedRequest {
@McpToolParam(description = "recipient phone number", required = true)
@io.swagger.v3.oas.annotations.media.Schema(pattern = "^01[0-9]{8,9}$")
private String phoneNumber;
@McpToolParam(description = "issue amount", required = true)
@io.swagger.v3.oas.annotations.media.Schema(minimum = "1")
private Long amount;
@McpToolParam(description = "approval result")
@io.swagger.v3.oas.annotations.media.Schema(
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
allowableValues = {"APPROVE", "REJECT"})
private String approvalStatus;
@McpToolParam(description = "page size")
@io.swagger.v3.oas.annotations.media.Schema(maximum = "50", defaultValue = "20")
private Integer pageSize;
@McpToolParam(description = "reference")
@io.swagger.v3.oas.annotations.media.Schema(minLength = 1, maxLength = 30)
private String reference;
}
@@ -115,6 +122,9 @@ class JsonSchemaGeneratorTest {
}
private static class NestedChild {
@io.swagger.v3.oas.annotations.media.Schema(
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
pattern = "^\\d{8}$")
private String businessDate;
}
}

View File

@@ -1,5 +1,9 @@
package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -12,6 +16,53 @@ import org.junit.jupiter.api.io.TempDir;
class ToolScaffolderTest {
@Test
void generatesV17ToolDefinitionTogetherWithToolSources() throws Exception {
String moduleName = root.resolve("dap-was-v17-definition").toString();
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회",
"사번으로 재직 중인 직원을 조회한다.", "smp", "HTTP", moduleName,
"tester", "2026.08.12", false, null, null, null,
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "09860000", true)),
List.of(), "employee");
Path definition = root.resolve("dap-was-v17-definition/src/main/resources/tool-definitions/smp/smp_employee_search.yml");
String yaml = Files.readString(definition);
assertTrue(yaml.contains("name: smp_employee_search"), yaml);
assertTrue(yaml.contains("when_to_use:"), yaml);
assertTrue(yaml.contains("example_queries:"), yaml);
assertTrue(yaml.contains("additionalProperties: false"), yaml);
assertTrue(yaml.contains("owner_org: \"MCP_TOOL\""), yaml);
ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(yaml, ToolDefinition.class);
ToolDefinitionValidator.validate(parsed, definition.toString());
}
@Test
void appliesV17MetadataEnteredByScaffoldUser() throws Exception {
String moduleName = root.resolve("dap-was-v17-options").toString();
ToolScaffolder.ToolDefinitionOptions options = new ToolScaffolder.ToolDefinitionOptions(
"사번으로 직원을 조회한다.",
"직원 정보 조회 요청에 사용한다.",
"사번이 없으면 사용하지 않는다.",
"최대 1건만 반환한다.",
"직원 기본 정보 조회",
List.of("사번 10001을 조회해줘", "직원 10001 소속을 알려줘", "10001 직원을 찾아줘"),
List.of("employee", "search"), "HR_TEAM");
ToolScaffolder.scaffold("employee search", "HR_EMPLOYEE_SEARCH", "직원 조회", "직원을 조회한다.",
"smp", "HTTP", moduleName, "tester", "2026.08.12", false, null, null, null,
List.of(new ToolScaffolder.FieldDefinition("employeeNo", "String", "조회할 사번", "10001", true)),
List.of(), "employee", options);
Path definition = root.resolve("dap-was-v17-options/src/main/resources/tool-definitions/smp/smp_employee_search.yml");
ToolDefinition parsed = new ObjectMapper(new YAMLFactory()).readValue(Files.readString(definition), ToolDefinition.class);
assertEquals("HR_TEAM", parsed.ownerOrg());
assertEquals("10001 직원을 찾아줘", parsed.exampleQueries().get(2));
assertEquals(2, parsed.tags().size());
ToolDefinitionValidator.validate(parsed, definition.toString());
}
@TempDir
Path root;

View File

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

View File

@@ -0,0 +1,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