feat: apply tool schema v17 metadata
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 22s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 22s
This commit is contained in:
@@ -10,6 +10,7 @@ public record ToolManifestItem(
|
||||
String title,
|
||||
String description,
|
||||
Map<String, Object> inputSchema,
|
||||
Map<String, Object> outputSchema,
|
||||
ToolManifestAnnotations annotations,
|
||||
@JsonProperty("_meta") ToolManifestMeta meta) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package io.shinhanlife.dap.lib.manifest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Operational metadata exposed by the Tool Service manifest. */
|
||||
public record ToolManifestMeta(String version, long timeoutMillis, boolean enabled) {
|
||||
}
|
||||
public record ToolManifestMeta(
|
||||
String version,
|
||||
long timeoutMillis,
|
||||
boolean enabled,
|
||||
List<String> exampleQueries,
|
||||
List<String> tags,
|
||||
String legacyInterfaceId,
|
||||
List<String> requiredEnvKeys,
|
||||
String ownerOrg) {
|
||||
}
|
||||
|
||||
@@ -61,12 +61,14 @@ public class ToolManifestService {
|
||||
? tool.getName() : tool.getDisplayName();
|
||||
Map<String, Object> schema = tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema();
|
||||
return new ToolManifestItem(
|
||||
tool.getName(), endpoint(tool), title, tool.getDescription(), schema,
|
||||
tool.getName(), endpoint(tool), title, tool.getDescription(), schema, tool.getOutputSchema(),
|
||||
new ToolManifestAnnotations(title, isTrue(tool.getReadOnlyHint()), isTrue(tool.getDestructiveHint()),
|
||||
isTrue(tool.getIdempotentHint()), isTrue(tool.getOpenWorldHint())),
|
||||
new ToolManifestMeta(defaultString(tool.getSemver(), "1.0.0"),
|
||||
tool.getTimeoutMillis() == null ? DEFAULT_TIMEOUT_MILLIS : tool.getTimeoutMillis(),
|
||||
tool.getEnabled() == null || tool.getEnabled()));
|
||||
tool.getEnabled() == null || tool.getEnabled(),
|
||||
defaultList(tool.getExampleQueries()), defaultList(tool.getTags()),
|
||||
tool.getMciServiceId(), defaultList(tool.getRequiredEnvKeys()), tool.getOwnerOrg()));
|
||||
}
|
||||
|
||||
private void validate(List<ToolManifestItem> tools) {
|
||||
@@ -128,4 +130,8 @@ public class ToolManifestService {
|
||||
private String defaultString(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> defaultList(List<String> value) {
|
||||
return value == null ? List.of() : List.copyOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.shinhanlife.dap.lib.mcp;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Registry 메타데이터를 MCP SDK Tool 명세로 일관되게 변환합니다. */
|
||||
public final class ToolMetadataMcpMapper {
|
||||
private ToolMetadataMcpMapper() {
|
||||
}
|
||||
|
||||
public static McpSchema.Tool toTool(ToolMetadata metadata) {
|
||||
McpSchema.Tool.Builder builder = McpSchema.Tool.builder()
|
||||
.name(metadata.getName())
|
||||
.title(defaultText(metadata.getDisplayName(), metadata.getName()))
|
||||
.description(defaultText(metadata.getDescription(), metadata.getName() + " Tool"))
|
||||
.inputSchema(toJsonSchema(metadata.getParametersSchema()))
|
||||
.annotations(new McpSchema.ToolAnnotations(
|
||||
metadata.getDisplayName(), metadata.getReadOnlyHint(), metadata.getDestructiveHint(),
|
||||
metadata.getIdempotentHint(), metadata.getOpenWorldHint(), null))
|
||||
.meta(meta(metadata));
|
||||
if (metadata.getOutputSchema() != null && !metadata.getOutputSchema().isEmpty()) {
|
||||
builder.outputSchema(metadata.getOutputSchema());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static Map<String, Object> meta(ToolMetadata metadata) {
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
put(meta, "version", metadata.getSemver());
|
||||
put(meta, "category_key", metadata.getCategoryKey());
|
||||
put(meta, "display_description", metadata.getDisplayDescription());
|
||||
put(meta, "example_queries", metadata.getExampleQueries());
|
||||
put(meta, "tags", metadata.getTags());
|
||||
put(meta, "legacy_interface_id", metadata.getMciServiceId());
|
||||
put(meta, "required_env_keys", metadata.getRequiredEnvKeys());
|
||||
put(meta, "owner_org", metadata.getOwnerOrg());
|
||||
return Map.copyOf(meta);
|
||||
}
|
||||
|
||||
private static void put(Map<String, Object> target, String key, Object value) {
|
||||
if (value instanceof String text && !text.isBlank()) {
|
||||
target.put(key, text);
|
||||
} else if (value instanceof List<?> list && !list.isEmpty()) {
|
||||
target.put(key, List.copyOf(list));
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultText(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static McpSchema.JsonSchema toJsonSchema(Map<String, Object> source) {
|
||||
Map<String, Object> schema = source == null ? emptySchema() : source;
|
||||
return new McpSchema.JsonSchema(
|
||||
String.valueOf(schema.getOrDefault("type", "object")),
|
||||
schema.get("properties") instanceof Map<?, ?> properties
|
||||
? (Map<String, Object>) properties : Map.of(),
|
||||
schema.get("required") instanceof List<?> required ? (List<String>) required : List.of(),
|
||||
schema.get("additionalProperties") instanceof Boolean additionalProperties
|
||||
? additionalProperties : Boolean.TRUE,
|
||||
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
|
||||
schema.get("definitions") instanceof Map<?, ?> definitions
|
||||
? (Map<String, Object>) definitions : Map.of());
|
||||
}
|
||||
|
||||
private static Map<String, Object> emptySchema() {
|
||||
return Map.of("type", "object", "properties", Map.of(), "additionalProperties", false);
|
||||
}
|
||||
}
|
||||
@@ -39,18 +39,7 @@ public class ToolPodMcpToolSynchronizer {
|
||||
}
|
||||
|
||||
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
|
||||
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
|
||||
.name(tool.getName())
|
||||
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
|
||||
.inputSchema(toJsonSchema(tool.getParametersSchema()))
|
||||
.annotations(new McpSchema.ToolAnnotations(
|
||||
tool.getDisplayName(),
|
||||
tool.getReadOnlyHint(),
|
||||
tool.getDestructiveHint(),
|
||||
tool.getIdempotentHint(),
|
||||
tool.getOpenWorldHint(),
|
||||
null))
|
||||
.build();
|
||||
McpSchema.Tool mcpTool = ToolMetadataMcpMapper.toTool(tool);
|
||||
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
|
||||
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinitionRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
@@ -29,14 +31,15 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -49,7 +52,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
@Component
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBean(McpToolExecutionService.class)
|
||||
public class ToolRegistryHeartbeatSender {
|
||||
|
||||
@@ -58,6 +60,23 @@ public class ToolRegistryHeartbeatSender {
|
||||
private final McpProperties mcpProperties;
|
||||
private final RestClient restClient = RestClient.create();
|
||||
private final ToolSchemaResolver toolSchemaResolver;
|
||||
private final ToolDefinitionRepository toolDefinitionRepository;
|
||||
|
||||
@Autowired
|
||||
public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper,
|
||||
McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver,
|
||||
@Nullable ToolDefinitionRepository toolDefinitionRepository) {
|
||||
this.applicationContext = applicationContext;
|
||||
this.objectMapper = objectMapper;
|
||||
this.mcpProperties = mcpProperties;
|
||||
this.toolSchemaResolver = toolSchemaResolver;
|
||||
this.toolDefinitionRepository = toolDefinitionRepository;
|
||||
}
|
||||
|
||||
public ToolRegistryHeartbeatSender(ApplicationContext applicationContext, ObjectMapper objectMapper,
|
||||
McpProperties mcpProperties, ToolSchemaResolver toolSchemaResolver) {
|
||||
this(applicationContext, objectMapper, mcpProperties, toolSchemaResolver, null);
|
||||
}
|
||||
|
||||
@Value("${axhub.gateway.url:http://localhost:8081}")
|
||||
private String gatewayUrl;
|
||||
@@ -143,11 +162,18 @@ public class ToolRegistryHeartbeatSender {
|
||||
// TODO: ToolSchemaResolver may need to be updated to take McpTool instead of McpFunction
|
||||
Map<String, Object> finalSchema = toolSchemaResolver.resolve(functionAnnotation, hintAnnotation, paramType);
|
||||
meta.setParametersSchema(finalSchema);
|
||||
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(
|
||||
functionAnnotation, method.getReturnType(), hintAnnotation);
|
||||
if (!outputSchema.isEmpty()) {
|
||||
meta.setOutputSchema(outputSchema);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate schema for {}", subToolName, e);
|
||||
}
|
||||
}
|
||||
|
||||
enrichWithDefinition(meta, rawSubToolName, hintAnnotation);
|
||||
|
||||
if (isRegister) {
|
||||
registeredTools.add(meta);
|
||||
}
|
||||
@@ -158,6 +184,45 @@ public class ToolRegistryHeartbeatSender {
|
||||
}
|
||||
}
|
||||
|
||||
private void enrichWithDefinition(ToolMetadata meta, String rawToolName, ToolHint hintAnnotation) {
|
||||
if (toolDefinitionRepository == null) {
|
||||
return;
|
||||
}
|
||||
toolDefinitionRepository.findByName(rawToolName)
|
||||
.ifPresent(definition -> applyDefinition(meta, definition, hintAnnotation));
|
||||
}
|
||||
|
||||
private void applyDefinition(ToolMetadata meta, ToolDefinition definition, ToolHint hintAnnotation) {
|
||||
meta.setDisplayName(definition.displayName());
|
||||
meta.setSemver(definition.version());
|
||||
meta.setCategoryKey(definition.categoryKey());
|
||||
meta.setFunctionDescription(definition.description().function());
|
||||
meta.setWhenToUse(definition.description().whenToUse());
|
||||
meta.setWhenNotToUse(definition.description().whenNotToUse());
|
||||
meta.setIoLimits(definition.description().ioLimits());
|
||||
meta.setDescription(String.join("\n", definition.description().function(),
|
||||
"사용 시점: " + definition.description().whenToUse(),
|
||||
"사용 제외: " + definition.description().whenNotToUse(),
|
||||
"입출력 제한: " + definition.description().ioLimits()));
|
||||
meta.setDisplayDescription(definition.displayDescription());
|
||||
meta.setExampleQueries(definition.exampleQueries());
|
||||
meta.setReadOnlyHint(definition.readOnly());
|
||||
meta.setDestructiveHint(definition.destructive());
|
||||
meta.setIdempotentHint(definition.idempotent());
|
||||
boolean explicitInputResource = hintAnnotation != null && !hintAnnotation.inputSchemaResource().isBlank();
|
||||
boolean explicitOutputResource = hintAnnotation != null && !hintAnnotation.outputSchemaResource().isBlank();
|
||||
if (!explicitInputResource) {
|
||||
meta.setParametersSchema(definition.parametersSchema());
|
||||
}
|
||||
if (!explicitOutputResource && definition.outputSchema() != null && !definition.outputSchema().isEmpty()) {
|
||||
meta.setOutputSchema(definition.outputSchema());
|
||||
}
|
||||
meta.setTags(definition.tags());
|
||||
meta.setMciServiceId(definition.legacyInterfaceId());
|
||||
meta.setRequiredEnvKeys(definition.requiredEnvKeys());
|
||||
meta.setOwnerOrg(definition.ownerOrg());
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 30000)
|
||||
public void sendHeartbeats() {
|
||||
if (registeredTools.isEmpty()) return;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** BC-DAB-STD-003 V17 Tool 정의 파일의 불변 모델입니다. */
|
||||
public record ToolDefinition(
|
||||
String name,
|
||||
@JsonProperty("display_name") String displayName,
|
||||
String version,
|
||||
@JsonProperty("category_key") String categoryKey,
|
||||
ToolDescription description,
|
||||
@JsonProperty("display_description") String displayDescription,
|
||||
@JsonProperty("example_queries") List<String> exampleQueries,
|
||||
@JsonProperty("read_only") Boolean readOnly,
|
||||
Boolean destructive,
|
||||
Boolean idempotent,
|
||||
@JsonProperty("parameters_schema") Map<String, Object> parametersSchema,
|
||||
@JsonProperty("output_schema") Map<String, Object> outputSchema,
|
||||
List<String> tags,
|
||||
@JsonProperty("legacy_interface_id") String legacyInterfaceId,
|
||||
@JsonProperty("required_env_keys") List<String> requiredEnvKeys,
|
||||
@JsonProperty("owner_org") String ownerOrg) {
|
||||
|
||||
public ToolDefinition withExampleQueries(List<String> queries) {
|
||||
return new ToolDefinition(name, displayName, version, categoryKey, description, displayDescription,
|
||||
queries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId,
|
||||
requiredEnvKeys, ownerOrg);
|
||||
}
|
||||
|
||||
public ToolDefinition withName(String value) {
|
||||
return new ToolDefinition(value, displayName, version, categoryKey, description, displayDescription,
|
||||
exampleQueries, readOnly, destructive, idempotent, parametersSchema, outputSchema, tags, legacyInterfaceId,
|
||||
requiredEnvKeys, ownerOrg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** classpath의 Tool 정의를 기동 시 한 번 읽어 name 기준으로 캐시합니다. */
|
||||
@Component
|
||||
public class ToolDefinitionRepository {
|
||||
public static final String DEFAULT_LOCATION = "classpath*:tool-definitions/**/*.yml";
|
||||
|
||||
private final Map<String, ToolDefinition> definitions;
|
||||
|
||||
@Autowired
|
||||
public ToolDefinitionRepository(ResourceLoader resourceLoader) {
|
||||
this(new ObjectMapper(new YAMLFactory()), resourceLoader, DEFAULT_LOCATION);
|
||||
}
|
||||
|
||||
public ToolDefinitionRepository(ObjectMapper yamlMapper, ResourceLoader resourceLoader, String location) {
|
||||
this(yamlMapper, new PathMatchingResourcePatternResolver(resourceLoader), location);
|
||||
}
|
||||
|
||||
private ToolDefinitionRepository(ObjectMapper yamlMapper, ResourcePatternResolver resolver, String location) {
|
||||
this.definitions = Collections.unmodifiableMap(load(yamlMapper, resolver, location));
|
||||
}
|
||||
|
||||
public Optional<ToolDefinition> findByName(String name) {
|
||||
return Optional.ofNullable(definitions.get(name));
|
||||
}
|
||||
|
||||
public Map<String, ToolDefinition> findAll() {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private Map<String, ToolDefinition> load(ObjectMapper mapper, ResourcePatternResolver resolver, String location) {
|
||||
Map<String, ToolDefinition> loaded = new LinkedHashMap<>();
|
||||
try {
|
||||
for (Resource resource : resolver.getResources(location)) {
|
||||
ToolDefinition definition = mapper.readValue(resource.getInputStream(), ToolDefinition.class);
|
||||
String source = resource.getDescription();
|
||||
ToolDefinitionValidator.validate(definition, source);
|
||||
ToolDefinition previous = loaded.putIfAbsent(definition.name(), definition);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Duplicate Tool definition name: " + definition.name());
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to load Tool definitions from " + location, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Tool 정의가 BC-DAB-STD-003 V17 필수 규칙을 만족하는지 검증합니다. */
|
||||
public final class ToolDefinitionValidator {
|
||||
private static final Pattern TOOL_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,63}$");
|
||||
|
||||
private ToolDefinitionValidator() {
|
||||
}
|
||||
|
||||
public static void validate(ToolDefinition definition, String source) {
|
||||
if (definition == null) {
|
||||
fail(source, "definition", "문서가 비어 있습니다");
|
||||
}
|
||||
if (isBlank(definition.name()) || !TOOL_NAME.matcher(definition.name()).matches()) {
|
||||
fail(source, "name", "^[a-z][a-z0-9_]{2,63}$ 형식이어야 합니다");
|
||||
}
|
||||
requireText(source, "display_name", definition.displayName());
|
||||
requireText(source, "version", definition.version());
|
||||
requireText(source, "category_key", definition.categoryKey());
|
||||
requireText(source, "display_description", definition.displayDescription());
|
||||
if (definition.description() == null) {
|
||||
fail(source, "description", "필수입니다");
|
||||
}
|
||||
requireText(source, "description.function", definition.description().function());
|
||||
requireText(source, "description.when_to_use", definition.description().whenToUse());
|
||||
requireText(source, "description.when_not_to_use", definition.description().whenNotToUse());
|
||||
requireText(source, "description.io_limits", definition.description().ioLimits());
|
||||
List<String> examples = definition.exampleQueries();
|
||||
if (examples == null || examples.size() < 3 || examples.size() > 10
|
||||
|| examples.stream().anyMatch(ToolDefinitionValidator::isBlank)) {
|
||||
fail(source, "example_queries", "비어 있지 않은 자연어 질의가 3~10개 필요합니다");
|
||||
}
|
||||
if (examples.stream().anyMatch(query -> query.contains(definition.name()))) {
|
||||
fail(source, "example_queries", "Tool name을 직접 포함할 수 없습니다");
|
||||
}
|
||||
if (definition.readOnly() == null || definition.destructive() == null || definition.idempotent() == null) {
|
||||
fail(source, "annotations", "read_only, destructive, idempotent는 필수입니다");
|
||||
}
|
||||
validateSchema(definition.parametersSchema(), source);
|
||||
if (definition.outputSchema() != null && !definition.outputSchema().isEmpty()) {
|
||||
validateSchema(definition.outputSchema(), source + " output_schema");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void validateSchema(Map<String, Object> schema, String source) {
|
||||
if (schema == null || !"object".equals(schema.get("type"))) {
|
||||
fail(source, "parameters_schema.type", "object여야 합니다");
|
||||
}
|
||||
if (!Boolean.FALSE.equals(schema.get("additionalProperties"))) {
|
||||
fail(source, "parameters_schema.additionalProperties", "false여야 합니다");
|
||||
}
|
||||
Object propertiesValue = schema.get("properties");
|
||||
if (!(propertiesValue instanceof Map<?, ?>)) {
|
||||
fail(source, "parameters_schema.properties", "object여야 합니다");
|
||||
}
|
||||
Map<?, ?> properties = (Map<?, ?>) propertiesValue;
|
||||
for (Map.Entry<?, ?> entry : properties.entrySet()) {
|
||||
if (!(entry.getValue() instanceof Map<?, ?> property)
|
||||
|| isBlank(String.valueOf(property.containsKey("description")
|
||||
? property.get("description") : ""))) {
|
||||
fail(source, "parameters_schema.properties." + entry.getKey() + ".description", "필수입니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String source, String field, String value) {
|
||||
if (isBlank(value)) {
|
||||
fail(source, field, "필수입니다");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static void fail(String source, String field, String message) {
|
||||
throw new IllegalStateException("Invalid Tool definition [" + source + "] " + field + ": " + message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/** LLM이 Tool 선택 여부를 판단할 때 사용하는 V17 목적 설명입니다. */
|
||||
public record ToolDescription(
|
||||
String function,
|
||||
@JsonProperty("when_to_use") String whenToUse,
|
||||
@JsonProperty("when_not_to_use") String whenNotToUse,
|
||||
@JsonProperty("io_limits") String ioLimits) {
|
||||
}
|
||||
@@ -47,6 +47,17 @@ public class ToolScaffolder {
|
||||
public record FieldDefinition(String name, String type, String description, String example, boolean required) {
|
||||
}
|
||||
|
||||
public record ToolDefinitionOptions(
|
||||
String functionDescription,
|
||||
String whenToUse,
|
||||
String whenNotToUse,
|
||||
String ioLimits,
|
||||
String displayDescription,
|
||||
List<String> exampleQueries,
|
||||
List<String> tags,
|
||||
String ownerOrg) {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.shinhanlife.dap.lib.validation;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinition;
|
||||
import io.shinhanlife.dap.lib.metadata.ToolDefinitionValidator;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** CI/CD에서 모든 Java MCP Tool과 V17 정의 파일의 1:1 대응을 검증합니다. */
|
||||
public final class ToolSchemaV17ValidationRunner {
|
||||
private static final Pattern MCP_TOOL_NAME = Pattern.compile(
|
||||
"@McpTool\\s*\\(\\s*name\\s*=\\s*\"([^\"]+)\"");
|
||||
|
||||
private ToolSchemaV17ValidationRunner() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length != 1) {
|
||||
throw new IllegalArgumentException("Usage: ToolSchemaV17ValidationRunner <project-root>");
|
||||
}
|
||||
validate(Path.of(args[0]));
|
||||
}
|
||||
|
||||
static void validate(Path projectRoot) {
|
||||
ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
|
||||
Map<String, Path> definitions = new LinkedHashMap<>();
|
||||
Set<String> toolNames = new LinkedHashSet<>();
|
||||
try (Stream<Path> files = Files.walk(projectRoot)) {
|
||||
for (Path file : files.filter(Files::isRegularFile).toList()) {
|
||||
String normalized = file.toString().replace('\\', '/');
|
||||
if (normalized.contains("/build/") || normalized.contains("/.gradle/")
|
||||
|| normalized.contains("/tmp_") || normalized.contains("/org/")) {
|
||||
continue;
|
||||
}
|
||||
if (normalized.endsWith(".java") && normalized.contains("/dap-was-")
|
||||
&& !normalized.contains("/dap-was-lib/")) {
|
||||
Matcher matcher = MCP_TOOL_NAME.matcher(Files.readString(file, StandardCharsets.UTF_8));
|
||||
while (matcher.find()) {
|
||||
toolNames.add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
if (normalized.contains("/src/main/resources/tool-definitions/")
|
||||
&& (normalized.endsWith(".yml") || normalized.endsWith(".yaml"))) {
|
||||
ToolDefinition definition = yamlMapper.readValue(file.toFile(), ToolDefinition.class);
|
||||
ToolDefinitionValidator.validate(definition, file.toString());
|
||||
Path previous = definitions.putIfAbsent(definition.name(), file);
|
||||
if (previous != null) {
|
||||
throw new IllegalStateException("Duplicate V17 Tool definition: " + definition.name()
|
||||
+ " [" + previous + ", " + file + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to scan Tool schema V17 files", exception);
|
||||
}
|
||||
Set<String> missing = new LinkedHashSet<>(toolNames);
|
||||
missing.removeAll(definitions.keySet());
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException("Missing V17 Tool definitions: " + missing);
|
||||
}
|
||||
Set<String> orphan = new LinkedHashSet<>(definitions.keySet());
|
||||
orphan.removeAll(toolNames);
|
||||
if (!orphan.isEmpty()) {
|
||||
throw new IllegalStateException("V17 definitions without matching @McpTool: " + orphan);
|
||||
}
|
||||
System.out.println("Tool schema V17 validation passed: " + toolNames.size() + " tools");
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,19 @@ public class ToolMetadata {
|
||||
private String displayName; // 사람이 읽는 라벨 (1-128자)
|
||||
private String name; // MCP 서브툴 명칭 (64자 이하, 예: CustomerSearchTool)
|
||||
private String description; // 툴의 목적 및 설명 (LLM 프롬프트에 활용 가능)
|
||||
private String functionDescription;
|
||||
private String whenToUse;
|
||||
private String whenNotToUse;
|
||||
private String ioLimits;
|
||||
private String displayDescription;
|
||||
private List<String> exampleQueries;
|
||||
private List<String> tags;
|
||||
private String ownerOrg;
|
||||
private List<String> requiredEnvKeys;
|
||||
|
||||
// 2. 파라미터 스키마 (JSON Schema 형태의 Map)
|
||||
private Map<String, Object> parametersSchema;
|
||||
private Map<String, Object> outputSchema;
|
||||
|
||||
// 2-0. 프론트엔드 UI용 함수별 프롬프트 매핑 (추가됨)
|
||||
private Map<String, String> actionPrompts;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package io.shinhanlife.dap.lib.metadata;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
|
||||
class ToolDefinitionRepositoryTest {
|
||||
|
||||
@Test
|
||||
void springCanCreateRepositoryWithoutDefaultConstructor() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(ToolDefinitionRepository.class);
|
||||
context.refresh();
|
||||
|
||||
assertTrue(context.getBean(ToolDefinitionRepository.class)
|
||||
.findByName("cmm_claim_search").isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsAndCachesAValidV17DefinitionByToolName() {
|
||||
ToolDefinitionRepository repository = new ToolDefinitionRepository(
|
||||
new ObjectMapper(new YAMLFactory()), new DefaultResourceLoader(),
|
||||
"classpath*:tool-definitions/**/*.yml");
|
||||
|
||||
ToolDefinition definition = repository.findByName("cmm_claim_search").orElseThrow();
|
||||
|
||||
assertEquals("보험금 청구 상태 조회", definition.displayName());
|
||||
assertEquals("cmm", definition.categoryKey());
|
||||
assertEquals(3, definition.exampleQueries().size());
|
||||
assertEquals(false, definition.parametersSchema().get("additionalProperties"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDefinitionWithFewerThanThreeExampleQueries() {
|
||||
ToolDefinition invalid = validDefinition().withExampleQueries(java.util.List.of("청구 상태 알려줘"));
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> ToolDefinitionValidator.validate(invalid, "memory:invalid"));
|
||||
|
||||
assertTrue(error.getMessage().contains("example_queries"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonStandardToolName() {
|
||||
ToolDefinition invalid = validDefinition().withName("cmm_claim.Search");
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> ToolDefinitionValidator.validate(invalid, "memory:invalid"));
|
||||
|
||||
assertTrue(error.getMessage().contains("name"));
|
||||
}
|
||||
|
||||
private ToolDefinition validDefinition() {
|
||||
return new ToolDefinition(
|
||||
"cmm_claim_search", "보험금 청구 상태 조회", "1.0.0", "cmm",
|
||||
new ToolDescription("청구 상태를 조회한다.", "상태 확인 시 사용한다.",
|
||||
"청구 접수 시 사용하지 않는다.", "청구번호가 필요하다."),
|
||||
"보험금 청구 상태를 조회합니다.",
|
||||
java.util.List.of("청구 상태 알려줘", "심사 결과 조회해줘", "계약번호로 청구를 찾아줘"),
|
||||
true, false, true,
|
||||
java.util.Map.of("type", "object", "properties", java.util.Map.of(),
|
||||
"additionalProperties", false),
|
||||
null,
|
||||
java.util.List.of("보험금"), null, java.util.List.of(), "MCP_TOOL");
|
||||
}
|
||||
}
|
||||
@@ -91,18 +91,25 @@ class JsonSchemaGeneratorTest {
|
||||
|
||||
private static class ValidatedRequest {
|
||||
@McpToolParam(description = "recipient phone number", required = true)
|
||||
@io.swagger.v3.oas.annotations.media.Schema(pattern = "^01[0-9]{8,9}$")
|
||||
private String phoneNumber;
|
||||
|
||||
@McpToolParam(description = "issue amount", required = true)
|
||||
@io.swagger.v3.oas.annotations.media.Schema(minimum = "1")
|
||||
private Long amount;
|
||||
|
||||
@McpToolParam(description = "approval result")
|
||||
@io.swagger.v3.oas.annotations.media.Schema(
|
||||
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"APPROVE", "REJECT"})
|
||||
private String approvalStatus;
|
||||
|
||||
@McpToolParam(description = "page size")
|
||||
@io.swagger.v3.oas.annotations.media.Schema(maximum = "50", defaultValue = "20")
|
||||
private Integer pageSize;
|
||||
|
||||
@McpToolParam(description = "reference")
|
||||
@io.swagger.v3.oas.annotations.media.Schema(minLength = 1, maxLength = 30)
|
||||
private String reference;
|
||||
}
|
||||
|
||||
@@ -115,6 +122,9 @@ class JsonSchemaGeneratorTest {
|
||||
}
|
||||
|
||||
private static class NestedChild {
|
||||
@io.swagger.v3.oas.annotations.media.Schema(
|
||||
requiredMode = io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED,
|
||||
pattern = "^\\d{8}$")
|
||||
private String businessDate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ class ToolManifestServiceTest {
|
||||
assertTrue(item.annotations().readOnlyHint());
|
||||
assertEquals("1.2.0", item.meta().version());
|
||||
assertEquals(3000, item.meta().timeoutMillis());
|
||||
assertEquals(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"),
|
||||
item.meta().exampleQueries());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,6 +88,9 @@ class ToolManifestServiceTest {
|
||||
.podUrl("http://tool-processing.ax-hub.svc.cluster.local:8080")
|
||||
.displayName("계약 조회")
|
||||
.description("계약번호로 계약 정보를 조회합니다.")
|
||||
.exampleQueries(List.of("계약 상태를 알려줘", "내 계약을 조회해줘", "계약번호로 찾아줘"))
|
||||
.tags(List.of("계약"))
|
||||
.ownerOrg("MCP_TOOL")
|
||||
.parametersSchema(Map.of("type", "object", "properties", Map.of("contractNo", Map.of("type", "string")),
|
||||
"required", List.of("contractNo"), "additionalProperties", false))
|
||||
.semver(version)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user