fix: dap-was-dapmt 바라보도록 수정

This commit is contained in:
juheelee
2026-08-20 14:58:50 +09:00
parent 4986f9801c
commit 52ae31d853
30 changed files with 495 additions and 359 deletions

View File

@@ -3,6 +3,7 @@ package io.shinhanlife.dat.report.application;
import io.shinhanlife.dat.report.excel.ToolReportExcelWriter;
import io.shinhanlife.dat.report.model.ToolReportModel;
import io.shinhanlife.dat.report.model.ToolSummary;
import io.shinhanlife.dat.report.model.ToolSourceType;
import io.shinhanlife.dat.report.source.ToolDetailAnalyzer;
import io.shinhanlife.dat.report.source.ToolSourceDiscovery;
import java.util.LinkedHashMap;
@@ -26,13 +27,18 @@ public class ToolReportService {
this.excelWriter = excelWriter;
}
public byte[] createExcel(List<String> selectedNames) {
public List<ToolSummary> findTools(String sourceType) {
return discovery.discover(ToolSourceType.from(sourceType));
}
public byte[] createExcel(List<String> selectedNames, String sourceTypeValue) {
if (selectedNames == null || selectedNames.isEmpty()) {
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
}
ToolSourceType sourceType = ToolSourceType.from(sourceTypeValue);
Map<String, ToolSummary> sourceTools = new LinkedHashMap<>();
discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool));
discovery.discover(sourceType).forEach(tool -> sourceTools.put(tool.name(), tool));
List<String> missingSources = selectedNames.stream()
.filter(name -> !sourceTools.containsKey(name)).distinct().toList();
if (!missingSources.isEmpty()) {
@@ -41,7 +47,7 @@ public class ToolReportService {
List<ToolReportModel> reports = selectedNames.stream().distinct()
.map(sourceTools::get)
.map(analyzer::analyze)
.map(tool -> analyzer.analyze(tool, sourceType))
.toList();
return excelWriter.write(reports);
}

View File

@@ -4,5 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
/** 보고서 소스 위치와 출력 정책 설정. */
@ConfigurationProperties(prefix = "report")
public record ReportProperties(String sourceRoot, String outputFilenamePrefix) {
public record ReportProperties(String dapWasDapmtSourceRoot,
String dapAdminSourceRoot,
String outputFilenamePrefix) {
}

View File

@@ -3,5 +3,5 @@ package io.shinhanlife.dat.report.model;
import java.util.List;
/** 사용자가 선택한 툴 보고서 생성 요청. */
public record ToolReportRequest(List<String> toolNames) {
public record ToolReportRequest(List<String> toolNames, String sourceType) {
}

View File

@@ -3,6 +3,8 @@ package io.shinhanlife.dat.report.presentation;
import io.shinhanlife.dat.report.application.ToolReportService;
import io.shinhanlife.dat.report.config.ReportProperties;
import io.shinhanlife.dat.report.model.ToolReportRequest;
import io.shinhanlife.dat.report.model.ToolSummary;
import java.util.List;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -12,10 +14,12 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestParam;
/** 툴 선택 목록과 Excel 다운로드 API. */
@RestController
@@ -33,7 +37,7 @@ public class ToolReportController {
@PostMapping(value = "/tool-reports/excel",
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public ResponseEntity<byte[]> excel(@RequestBody ToolReportRequest request) {
byte[] content = service.createExcel(request.toolNames());
byte[] content = service.createExcel(request.toolNames(), request.sourceType());
String prefix = properties.outputFilenamePrefix() == null || properties.outputFilenamePrefix().isBlank()
? "tool-report" : properties.outputFilenamePrefix();
String filename = prefix + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
@@ -47,6 +51,11 @@ public class ToolReportController {
.body(content);
}
@GetMapping("/report-tools")
public List<ToolSummary> tools(@RequestParam(required = false) String sourceType) {
return service.findTools(sourceType);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> badRequest(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dat.report.source;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MemberValuePair;
@@ -9,6 +10,7 @@ import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import java.util.Optional;
import java.util.stream.Collectors;
/** JavaParser AST에서 어노테이션 값을 안전하게 읽는 도우미. */
final class JavaAnnotationReader {
@@ -49,6 +51,18 @@ final class JavaAnnotationReader {
.orElse(fallback);
}
static String strings(AnnotationExpr annotation, String key) {
return value(annotation, key)
.filter(ArrayInitializerExpr.class::isInstance)
.map(ArrayInitializerExpr.class::cast)
.map(array -> array.getValues().stream()
.filter(StringLiteralExpr.class::isInstance)
.map(StringLiteralExpr.class::cast)
.map(StringLiteralExpr::asString)
.collect(Collectors.joining(" | ")))
.orElse("");
}
static Optional<AnnotationExpr> nested(AnnotationExpr annotation, String key) {
return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast);
}

View File

@@ -1,8 +1,5 @@
package io.shinhanlife.dat.report.source;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.FieldDeclaration;
@@ -10,135 +7,56 @@ import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import io.shinhanlife.dat.report.model.FieldDefinition;
import io.shinhanlife.dat.report.model.ToolReportModel;
import io.shinhanlife.dat.report.model.ToolSourceType;
import io.shinhanlife.dat.report.model.ToolSummary;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/** 선택된 툴의 DTO, JSON Schema와 원천 전문 필드를 수집한다. */
/** 선택한 프로젝트의 DTO와 전문 Java 어노테이션에서 Report 파라미터를 수집한다. */
@Component
public class ToolDetailAnalyzer {
private final ToolSourceDiscovery discovery;
private final ObjectMapper objectMapper;
private final ObjectMapper yamlMapper;
public ToolDetailAnalyzer(ToolSourceDiscovery discovery) {
this.discovery = discovery;
this.objectMapper = new ObjectMapper();
this.yamlMapper = new ObjectMapper(new YAMLFactory());
}
public ToolReportModel analyze(ToolSummary tool) {
public ToolReportModel analyze(ToolSummary tool, ToolSourceType sourceType) {
List<FieldDefinition> fields = new ArrayList<>();
List<String> diagnostics = new ArrayList<>();
Map<String, Path> javaFiles = indexJavaFiles();
Path sourceRoot = discovery.sourceRoot(sourceType);
Map<String, Path> javaFiles = indexJavaFiles(sourceRoot);
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
boolean definitionInput = collectDefinitionSchema(tool, "parameters_schema", "INPUT", fields, diagnostics);
boolean definitionOutput = collectDefinitionSchema(tool, "output_schema", "OUTPUT", fields, diagnostics);
if (tool.inputSchemaResource().isBlank() && !definitionInput) {
collectJavaType(tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
}
if (tool.outputSchemaResource().isBlank() && !definitionOutput) {
collectJavaType(tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
}
collectJavaType(sourceRoot, tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
collectJavaType(sourceRoot, tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
if (!tool.mappingId().isBlank()) {
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_I"), "TELEGRAM", "INPUT", fields, diagnostics);
collectJavaType(tool, javaFiles.get(tool.mappingId() + "_O"), "TELEGRAM", "OUTPUT", fields, diagnostics);
collectJavaType(sourceRoot, tool, javaFiles.get(tool.mappingId() + "_I"),
"TELEGRAM", "INPUT", fields, diagnostics);
collectJavaType(sourceRoot, tool, javaFiles.get(tool.mappingId() + "_O"),
"TELEGRAM", "OUTPUT", fields, diagnostics);
}
return new ToolReportModel(tool, List.copyOf(fields), List.copyOf(diagnostics));
}
private boolean collectDefinitionSchema(ToolSummary tool, String schemaKey, String direction,
List<FieldDefinition> fields, List<String> diagnostics) {
if (tool.definitionFile() == null || tool.definitionFile().isBlank()) return false;
Path path = discovery.sourceRoot().resolve(tool.definitionFile()).normalize();
try {
JsonNode schema = yamlMapper.readTree(path.toFile()).path(schemaKey);
if (schema.isMissingNode() || schema.isNull() || schema.isEmpty()) return false;
collectSchemaFields(tool, schema, "TOOL_DEFINITION", direction, "", "", relative(path), fields);
return true;
} catch (Exception exception) {
diagnostics.add("Tool definition schema 분석 실패: " + relative(path) + " (" + schemaKey + ")");
return false;
}
}
private Map<String, Path> indexJavaFiles() {
private Map<String, Path> indexJavaFiles(Path sourceRoot) {
Map<String, Path> result = new LinkedHashMap<>();
try (var paths = Files.walk(discovery.sourceRoot())) {
try (var paths = Files.walk(sourceRoot)) {
paths.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".java"))
.forEach(path -> result.putIfAbsent(path.getFileName().toString().replace(".java", ""), path));
} catch (IOException exception) {
throw new IllegalStateException("Failed to index Java sources", exception);
throw new IllegalStateException("Failed to index Java sources: " + sourceRoot, exception);
}
return result;
}
private void collectSchema(ToolSummary tool, String resource, String direction,
List<FieldDefinition> fields, List<String> diagnostics) {
if (resource == null || resource.isBlank()) return;
String relative = resource.replaceFirst("^classpath:", "").replaceFirst("^/", "");
List<Path> matches = new ArrayList<>();
try (var paths = Files.walk(discovery.sourceRoot())) {
paths.filter(path -> Files.isRegularFile(path))
.filter(path -> path.toString().replace('\\', '/').endsWith("/src/main/resources/" + relative))
.forEach(matches::add);
} catch (IOException exception) {
diagnostics.add("Schema 검색 실패: " + resource);
return;
}
if (matches.isEmpty()) {
diagnostics.add("Schema를 찾을 수 없음: " + resource);
return;
}
Path path = matches.get(0);
try {
JsonNode root = objectMapper.readTree(path.toFile());
collectSchemaFields(tool, root, "JSON_SCHEMA", direction, "", "", relative(path), fields);
} catch (Exception exception) {
diagnostics.add("Schema 분석 실패: " + relative(path));
}
}
private void collectSchemaFields(ToolSummary tool, JsonNode schema, String sourceKind, String direction,
String ownerPath, String fieldPrefix, String sourceFile,
List<FieldDefinition> fields) {
List<String> required = new ArrayList<>();
schema.path("required").forEach(node -> required.add(node.asText()));
Iterator<Map.Entry<String, JsonNode>> iterator = schema.path("properties").fields();
while (iterator.hasNext()) {
Map.Entry<String, JsonNode> entry = iterator.next();
JsonNode definition = entry.getValue();
String fieldName = fieldPrefix.isBlank() ? entry.getKey() : fieldPrefix + "." + entry.getKey();
String type = schemaType(definition);
fields.add(new FieldDefinition(tool.name(), sourceKind, direction, ownerPath, fieldName, type,
required.contains(entry.getKey()), definition.path("description").asText(""),
constraints(definition), sourceFile));
JsonNode nested = definition.path("properties").isObject() ? definition : definition.path("items");
if (nested.path("properties").isObject()) {
collectSchemaFields(tool, nested, sourceKind, direction, fieldName, fieldName, sourceFile, fields);
}
}
}
private String schemaType(JsonNode definition) {
String type = definition.path("type").asText("object");
if ("array".equals(type)) {
return "array<" + definition.path("items").path("type").asText("object") + ">";
}
return type;
}
private void collectJavaType(ToolSummary tool, Path path, String sourceKind, String direction,
private void collectJavaType(Path sourceRoot, ToolSummary tool, Path path, String sourceKind, String direction,
List<FieldDefinition> fields, List<String> diagnostics) {
if (path == null) {
diagnostics.add(sourceKind + " " + direction + " 타입을 찾을 수 없음");
@@ -147,18 +65,18 @@ public class ToolDetailAnalyzer {
try {
CompilationUnit unit = StaticJavaParser.parse(path);
for (TypeDeclaration<?> type : unit.getTypes()) {
collectFields(tool, path, type, sourceKind, direction, fields);
collectFields(sourceRoot, tool, path, type, sourceKind, direction, fields);
type.getMembers().stream().filter(TypeDeclaration.class::isInstance)
.map(TypeDeclaration.class::cast)
.forEach(nested -> collectFields(tool, path, nested, sourceKind, direction, fields));
.forEach(nested -> collectFields(sourceRoot, tool, path, nested, sourceKind, direction, fields));
}
} catch (Exception exception) {
diagnostics.add(sourceKind + " 분석 실패: " + relative(path));
diagnostics.add(sourceKind + " 분석 실패: " + relative(sourceRoot, path));
}
}
private void collectFields(ToolSummary tool, Path path, TypeDeclaration<?> owner, String sourceKind,
String direction, List<FieldDefinition> fields) {
private void collectFields(Path sourceRoot, ToolSummary tool, Path path, TypeDeclaration<?> owner,
String sourceKind, String direction, List<FieldDefinition> fields) {
for (FieldDeclaration declaration : owner.getFields()) {
AnnotationExpr param = JavaAnnotationReader.find(declaration.getAnnotations(), "McpToolParam").orElse(null);
AnnotationExpr schema = JavaAnnotationReader.find(declaration.getAnnotations(), "Schema").orElse(null);
@@ -168,7 +86,7 @@ public class ToolDetailAnalyzer {
variable.getTypeAsString(),
param == null ? null : JavaAnnotationReader.bool(param, "required", false),
param == null ? "" : JavaAnnotationReader.string(param, "description", ""),
annotationConstraints(schema, telegram), relative(path))));
annotationConstraints(schema, telegram), relative(sourceRoot, path))));
}
}
@@ -179,16 +97,7 @@ public class ToolDetailAnalyzer {
return String.join("; ", values);
}
private String constraints(JsonNode node) {
List<String> values = new ArrayList<>();
for (String key : List.of("format", "pattern", "minimum", "maximum", "minLength", "maxLength", "example")) {
if (node.has(key)) values.add(key + "=" + node.get(key).asText());
}
if (node.has("enum")) values.add("enum=" + node.get("enum"));
return String.join("; ", values);
}
private String relative(Path path) {
return discovery.sourceRoot().relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
private String relative(Path sourceRoot, Path path) {
return sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
}
}

View File

@@ -4,11 +4,9 @@ import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.shinhanlife.dat.report.config.ReportProperties;
import io.shinhanlife.dat.report.model.ToolSummary;
import io.shinhanlife.dat.report.model.ToolSourceType;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -17,120 +15,50 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.EnumMap;
import org.springframework.stereotype.Component;
/** 프로젝트의 @McpTool 선언을 읽기 전용으로 탐색한다. */
@Component
public class ToolSourceDiscovery {
private final Path sourceRoot;
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
private final Map<ToolSourceType, Path> sourceRoots = new EnumMap<>(ToolSourceType.class);
public ToolSourceDiscovery(ReportProperties properties) {
String configuredRoot = properties.sourceRoot();
if (configuredRoot == null || configuredRoot.isBlank()) {
throw new IllegalArgumentException("report.source-root must not be blank");
}
this.sourceRoot = resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
sourceRoots.put(ToolSourceType.DAP_WAS_DAPMT,
resolveConfiguredRoot(properties.dapWasDapmtSourceRoot(), "report.dap-was-dapmt-source-root"));
sourceRoots.put(ToolSourceType.DAP_ADMIN,
resolveConfiguredRoot(properties.dapAdminSourceRoot(), "report.dap-admin-source-root"));
}
public Path sourceRoot() {
return sourceRoot;
public Path sourceRoot(ToolSourceType sourceType) {
return sourceRoots.get(sourceType);
}
public List<ToolSummary> discover() {
public List<ToolSummary> discover(ToolSourceType sourceType) {
Path sourceRoot = sourceRoot(sourceType);
if (!Files.isDirectory(sourceRoot)) {
throw new IllegalStateException("Report source root does not exist: " + sourceRoot);
}
List<ToolSummary> tools = new ArrayList<>();
try (var paths = Files.walk(sourceRoot)) {
paths.filter(this::isUseCaseSource).forEach(path -> parse(path, tools));
paths.filter(this::isUseCaseSource).forEach(path -> parse(sourceRoot, path, tools));
} catch (IOException exception) {
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
}
Map<String, ToolSummary> discovered = tools.stream()
return tools.stream()
.sorted(Comparator.comparing(ToolSummary::sourceFile))
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
(first, duplicate) -> first, LinkedHashMap::new));
discoverDefinitions().forEach((name, definition) ->
discovered.merge(name, definition, this::mergeDefinition));
return discovered.values().stream()
.collect(java.util.stream.Collectors.toMap(ToolSummary::name, java.util.function.Function.identity(),
(first, duplicate) -> first, LinkedHashMap::new)).values().stream()
.sorted(Comparator.comparing(ToolSummary::name))
.toList();
}
private Map<String, ToolSummary> discoverDefinitions() {
Map<String, ToolSummary> definitions = new LinkedHashMap<>();
try (var paths = Files.walk(sourceRoot)) {
paths.filter(this::isToolDefinition).sorted().forEach(path -> {
try {
JsonNode root = yamlMapper.readTree(path.toFile());
String name = text(root, "name");
if (name.isBlank()) return;
JsonNode description = root.path("description");
String function = text(description, "function");
definitions.putIfAbsent(name, new ToolSummary(
name, text(root, "display_name"), function, text(root, "category_key"),
text(root, "legacy_interface_id"), true, false,
bool(root, "read_only"), bool(root, "destructive"), bool(root, "idempotent"), false,
"", "", "", "", "", "", text(root, "version"), function,
text(description, "when_to_use"), text(description, "when_not_to_use"),
text(description, "io_limits"), text(root, "display_description"),
joined(root.path("example_queries")), joined(root.path("tags")),
joined(root.path("required_env_keys")), text(root, "owner_org"), relative(path)));
} catch (IOException exception) {
throw new IllegalStateException("Failed to parse tool definition: " + path, exception);
}
});
} catch (IOException exception) {
throw new IllegalStateException("Failed to scan tool definitions: " + sourceRoot, exception);
private Path resolveConfiguredRoot(String configuredRoot, String propertyName) {
if (configuredRoot == null || configuredRoot.isBlank()) {
throw new IllegalArgumentException(propertyName + " must not be blank");
}
return definitions;
}
private ToolSummary mergeDefinition(ToolSummary source, ToolSummary definition) {
return new ToolSummary(source.name(), prefer(definition.title(), source.title()),
prefer(definition.description(), source.description()),
prefer(definition.categoryKey(), source.categoryKey()),
prefer(definition.mappingId(), source.mappingId()), source.register(), source.requiresApproval(),
definition.readOnlyHint(), definition.destructiveHint(), definition.idempotentHint(),
source.openWorldHint(), source.requestType(), source.responseType(), source.useCaseClass(),
source.sourceFile(), source.inputSchemaResource(), source.outputSchemaResource(), definition.version(),
definition.functionDescription(), definition.whenToUse(), definition.whenNotToUse(),
definition.ioLimits(), definition.displayDescription(), definition.exampleQueries(), definition.tags(),
definition.requiredEnvKeys(), definition.ownerOrg(), definition.definitionFile());
}
private boolean isToolDefinition(Path path) {
String normalized = path.toString().replace('\\', '/');
String fileName = path.getFileName().toString().toLowerCase();
return Files.isRegularFile(path) && (fileName.endsWith(".yml") || fileName.endsWith(".yaml"))
&& normalized.contains("/src/main/resources/tool-definitions/");
}
private String text(JsonNode node, String field) {
return node.path(field).asText("");
}
private boolean bool(JsonNode node, String field) {
return node.path(field).asBoolean(false);
}
private String joined(JsonNode node) {
if (!node.isArray()) return node.asText("");
List<String> values = new ArrayList<>();
node.forEach(value -> values.add(value.asText()));
return String.join(" | ", values);
}
private String prefer(String primary, String fallback) {
return primary == null || primary.isBlank() ? fallback : primary;
}
private String relative(Path path) {
return sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/');
return resolveProjectRoot(Path.of(configuredRoot).toAbsolutePath().normalize());
}
private Path resolveProjectRoot(Path configuredPath) {
@@ -153,20 +81,20 @@ public class ToolSourceDiscovery {
&& !normalized.contains("/dap-tool-report/");
}
private void parse(Path path, List<ToolSummary> tools) {
private void parse(Path sourceRoot, Path path, List<ToolSummary> tools) {
try {
CompilationUnit unit = StaticJavaParser.parse(path);
String owner = unit.getPrimaryTypeName().orElse(path.getFileName().toString().replace(".java", ""));
for (MethodDeclaration method : unit.findAll(MethodDeclaration.class)) {
JavaAnnotationReader.find(method.getAnnotations(), "McpTool")
.ifPresent(annotation -> tools.add(toSummary(path, owner, method, annotation)));
.ifPresent(annotation -> tools.add(toSummary(sourceRoot, path, owner, method, annotation)));
}
} catch (Exception exception) {
throw new IllegalStateException("Failed to parse tool source: " + path, exception);
}
}
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
private ToolSummary toSummary(Path sourceRoot, Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "GrowToolHint")
.or(() -> JavaAnnotationReader.find(method.getAnnotations(), "ToolHint")).orElse(null);
AnnotationExpr annotations = JavaAnnotationReader.nested(tool, "annotations").orElse(null);
@@ -183,14 +111,30 @@ public class ToolSourceDiscovery {
hint != null && JavaAnnotationReader.bool(hint, "register", false),
hint != null && JavaAnnotationReader.bool(hint, "requiresApproval", false),
annotations != null && JavaAnnotationReader.bool(annotations, "readOnlyHint", false),
annotations != null && JavaAnnotationReader.bool(annotations, "destructiveHint", false),
annotations != null && JavaAnnotationReader.bool(annotations, "idempotentHint", false),
annotations != null
? JavaAnnotationReader.bool(annotations, "destructiveHint",
hint != null && JavaAnnotationReader.bool(hint, "destructive", false))
: hint != null && JavaAnnotationReader.bool(hint, "destructive", false),
annotations != null
? JavaAnnotationReader.bool(annotations, "idempotentHint",
hint != null && JavaAnnotationReader.bool(hint, "idempotent", false))
: hint != null && JavaAnnotationReader.bool(hint, "idempotent", false),
annotations != null && JavaAnnotationReader.bool(annotations, "openWorldHint", false),
requestType,
method.getTypeAsString(),
owner,
sourceRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'),
hint == null ? "" : JavaAnnotationReader.string(hint, "inputSchemaResource", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""));
hint == null ? "" : JavaAnnotationReader.string(hint, "outputSchemaResource", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "version", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "functionDescription", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "whenToUse", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "whenNotToUse", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "ioLimits", ""),
hint == null ? "" : JavaAnnotationReader.string(hint, "displayDescription", ""),
hint == null ? "" : JavaAnnotationReader.strings(hint, "exampleQueries"),
hint == null ? "" : JavaAnnotationReader.strings(hint, "tags"),
hint == null ? "" : JavaAnnotationReader.strings(hint, "requiredEnvKeys"),
hint == null ? "" : JavaAnnotationReader.string(hint, "ownerOrg", ""), "");
}
}

View File

@@ -7,5 +7,6 @@ spring:
name: dap-tool-report
report:
source-root: ${REPORT_SOURCE_ROOT:.}
dap-was-dapmt-source-root: ${REPORT_DAP_WAS_DAPMT_SOURCE_ROOT:../dap-was-dapmt}
dap-admin-source-root: ${REPORT_DAP_ADMIN_SOURCE_ROOT:.}
output-filename-prefix: tool-report

View File

@@ -3,49 +3,53 @@ package io.shinhanlife.dat.report.source;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dat.report.config.ReportProperties;
import io.shinhanlife.dat.report.model.ToolSourceType;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class ToolSourceDiscoveryTest {
private final Path adminRoot = projectRoot();
private final Path dapmtRoot = adminRoot.resolve("../dap-was-dapmt").normalize();
@Test
void discoversMcpToolsFromProjectSources() {
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
new ReportProperties(projectRoot.toString(), "tool-report"));
void discoversToolsFromBothSelectableProjects() {
ToolSourceDiscovery discovery = discovery();
assertThat(discovery.discover())
assertThat(discovery.discover(ToolSourceType.DAP_ADMIN))
.extracting(tool -> tool.name())
.contains("cmm_customer_tool", "cmm_claim_search", "iam_system_status");
assertThat(discovery.discover().stream()
.filter(tool -> tool.name().equals("cmm_customer_tool"))
.findFirst().orElseThrow())
.satisfies(tool -> {
assertThat(tool.version()).isEqualTo("1.0.0");
assertThat(tool.mappingId()).isEqualTo("ONILD0320");
assertThat(tool.definitionFile()).endsWith("tool-definitions/cmm/cmm_customer_tool.yml");
assertThat(tool.whenToUse()).contains("고객 ID");
});
.contains("cmm_claim_search");
assertThat(discovery.discover(ToolSourceType.DAP_WAS_DAPMT))
.extracting(tool -> tool.name())
.contains("spr_field_inquiry_list");
}
@Test
void collectsJsonSchemaFieldsEmbeddedInToolDefinitionYaml() {
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
new ReportProperties(projectRoot.toString(), "tool-report"));
var tool = discovery.discover().stream()
.filter(candidate -> candidate.name().equals("cmm_customer_tool"))
void collectsParametersFromDtoAnnotationsInsteadOfYamlDefinitions() {
ToolSourceDiscovery discovery = discovery();
var tool = discovery.discover(ToolSourceType.DAP_WAS_DAPMT).stream()
.filter(candidate -> candidate.name().equals("spr_field_inquiry_list"))
.findFirst().orElseThrow();
var report = new ToolDetailAnalyzer(discovery).analyze(tool);
var report = new ToolDetailAnalyzer(discovery).analyze(tool, ToolSourceType.DAP_WAS_DAPMT);
assertThat(report.fields())
.anySatisfy(field -> {
assertThat(field.sourceKind()).isEqualTo("TOOL_DEFINITION");
assertThat(field.direction()).isEqualTo("INPUT");
assertThat(field.fieldName()).isEqualTo("csNo");
assertThat(field.required()).isTrue();
});
assertThat(report.fields()).anySatisfy(field -> {
assertThat(field.sourceKind()).isEqualTo("DTO");
assertThat(field.direction()).isEqualTo("INPUT");
assertThat(field.fieldName()).isEqualTo("inquiryId");
assertThat(field.constraints()).contains("Schema=");
});
assertThat(report.fields()).noneMatch(field -> field.sourceKind().equals("TOOL_DEFINITION"));
}
private ToolSourceDiscovery discovery() {
return new ToolSourceDiscovery(new ReportProperties(
dapmtRoot.toString(), adminRoot.toString(), "tool-report"));
}
private Path projectRoot() {
Path current = Path.of("").toAbsolutePath().normalize();
return java.nio.file.Files.isRegularFile(current.resolve("settings.gradle"))
? current : current.getParent();
}
}