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;