fix: tool report 수정
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 6m11s

This commit is contained in:
juheelee
2026-08-14 13:39:50 +09:00
parent 864884f5cb
commit c6632eac59
15 changed files with 241 additions and 39 deletions

View File

@@ -154,6 +154,11 @@ public class McpRouterController {
return activeTools;
}
/** Report 화면처럼 내부 메타데이터 전체가 필요한 Gateway 컴포넌트에 활성 Tool 목록을 제공합니다. */
List<ToolMetadata> activeToolsForReport() {
return fetchAllActiveTools();
}
@GetMapping("/tools/list")
public ResponseEntity<JsonRpcResponse> listTools(
@RequestParam(value = "categoryKey", required = false) String categoryKey) {
@@ -280,4 +285,4 @@ public class McpRouterController {
}
}
}

View File

@@ -1,6 +1,5 @@
package io.shinhanlife.dap.mcg.presentation;
import io.shinhanlife.dap.lib.adapter.dto.JsonRpcResponse;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
@@ -30,7 +29,7 @@ public class ToolReportProxyController {
public ToolReportProxyController(
RestClient.Builder restClientBuilder,
McpRouterController mcpRouterController,
@Value("${report.service-url:http://127.0.0.1:8092}") String reportServiceUrl) {
@Value("${report.service-url}") String reportServiceUrl) {
this.restClient = restClientBuilder.build();
this.mcpRouterController = mcpRouterController;
this.reportServiceUrl = reportServiceUrl.replaceAll("/+$", "");
@@ -75,18 +74,7 @@ public class ToolReportProxyController {
}
private List<ToolMetadata> activeTools() {
ResponseEntity<JsonRpcResponse> response = mcpRouterController.listTools(null);
JsonRpcResponse body = response.getBody();
if (body == null || !(body.getResult() instanceof Map<?, ?> result)
|| !(result.get("tools") instanceof List<?> tools)) {
throw new IllegalStateException("MCP tools/list response does not contain result.tools");
}
return tools.stream().map(item -> {
if (!(item instanceof ToolMetadata metadata)) {
throw new IllegalStateException("MCP tools/list contains an invalid tool entry");
}
return metadata;
}).toList();
return mcpRouterController.activeToolsForReport();
}
private ResponseEntity<byte[]> copy(ResponseEntity<byte[]> response) {

View File

@@ -52,3 +52,6 @@ mcp:
logging:
level:
org.apache.kafka: ERROR
report:
service-url: ${REPORT_SERVICE_URL:http://127.0.0.1:8092}

View File

@@ -24,6 +24,9 @@ server:
enabled: true
shutdown: graceful
report:
service-url: ${REPORT_SERVICE_URL:http://tool-report:8092}
mcp:
gateway:
fallback:

View File

@@ -760,6 +760,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center">

View File

@@ -156,8 +156,15 @@
async function load() {
try {
const response = await fetch('/report/api/report-tools');
if (!response.ok) throw new Error('툴 목록 조회에 실패했습니다.');
state.tools = await response.json();
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error || body?.message || `툴 목록 조회에 실패했습니다. (${response.status})`);
}
const tools = Array.isArray(body) ? body : body?.result?.tools;
if (!Array.isArray(tools)) {
throw new Error('툴 목록 응답 형식이 올바르지 않습니다.');
}
state.tools = tools;
[...new Set(state.tools.map(tool => tool.categoryKey))].sort().forEach(category => {
const option = document.createElement('option');
option.value = category; option.textContent = category; el('category').appendChild(option);

View File

@@ -5,6 +5,7 @@ plugins {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
implementation 'com.github.javaparser:javaparser-core:3.26.3'
implementation 'org.apache.poi:poi-ooxml:5.3.0'

View File

@@ -36,6 +36,7 @@ public class ToolReportExcelWriter {
try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
Styles styles = new Styles(workbook);
writeSummary(workbook, reports, styles);
writeDefinitions(workbook, reports, styles);
writeFields(workbook, reports, styles);
writeDiagnostics(workbook, reports, styles);
workbook.write(output);
@@ -45,6 +46,29 @@ public class ToolReportExcelWriter {
}
}
private void writeDefinitions(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "버전", "표시 설명", "기능 설명", "사용 시점", "사용 제외",
"입출력 제한", "예시 질의", "태그", "필수 환경변수", "소유 조직", "정의 파일"};
Sheet sheet = workbook.createSheet("툴 정의");
decorateSheet(sheet, "Tool Definition Report", "tool-definitions YAML 메타데이터", reports,
headers.length, styles);
header(sheet, headers, styles);
int rowIndex = FIRST_DATA_ROW;
int sequence = 1;
for (ToolReportModel report : reports) {
ToolSummary tool = report.tool();
Row row = sheet.createRow(rowIndex++);
Object[] values = {sequence++, tool.name(), tool.version(), tool.displayDescription(),
tool.functionDescription(), tool.whenToUse(), tool.whenNotToUse(), tool.ioLimits(),
tool.exampleQueries(), tool.tags(), tool.requiredEnvKeys(), tool.ownerOrg(), tool.definitionFile()};
values(row, values, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
row.getCell(2).setCellStyle(styles.bodyCenter);
}
finishTable(sheet, rowIndex, headers.length,
new int[]{7, 30, 12, 40, 48, 48, 48, 48, 60, 30, 30, 20, 65});
}
private void writeSummary(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "제목", "설명", "카테고리", "연계 ID", "등록", "승인 필요",
"Read Only", "Destructive", "Idempotent", "Open World", "Request", "Response", "UseCase", "원천 파일"};

View File

@@ -18,5 +18,25 @@ public record ToolSummary(
String useCaseClass,
String sourceFile,
String inputSchemaResource,
String outputSchemaResource) {
String outputSchemaResource,
String version,
String functionDescription,
String whenToUse,
String whenNotToUse,
String ioLimits,
String displayDescription,
String exampleQueries,
String tags,
String requiredEnvKeys,
String ownerOrg,
String definitionFile) {
public ToolSummary(String name, String title, String description, String categoryKey, String mappingId,
boolean register, boolean requiresApproval, boolean readOnlyHint, boolean destructiveHint,
boolean idempotentHint, boolean openWorldHint, String requestType, String responseType,
String useCaseClass, String sourceFile, String inputSchemaResource, String outputSchemaResource) {
this(name, title, description, categoryKey, mappingId, register, requiresApproval, readOnlyHint,
destructiveHint, idempotentHint, openWorldHint, requestType, responseType, useCaseClass, sourceFile,
inputSchemaResource, outputSchemaResource, "", "", "", "", "", "", "", "", "", "", "");
}
}

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dap.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;
@@ -26,10 +27,12 @@ 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) {
@@ -39,10 +42,12 @@ public class ToolDetailAnalyzer {
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
if (tool.inputSchemaResource().isBlank()) {
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()) {
if (tool.outputSchemaResource().isBlank() && !definitionOutput) {
collectJavaType(tool, javaFiles.get(tool.responseType()), "DTO", "OUTPUT", fields, diagnostics);
}
if (!tool.mappingId().isBlank()) {
@@ -52,6 +57,21 @@ public class ToolDetailAnalyzer {
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() {
Map<String, Path> result = new LinkedHashMap<>();
try (var paths = Files.walk(discovery.sourceRoot())) {
@@ -83,23 +103,41 @@ public class ToolDetailAnalyzer {
Path path = matches.get(0);
try {
JsonNode root = objectMapper.readTree(path.toFile());
JsonNode properties = root.path("properties");
List<String> required = new ArrayList<>();
root.path("required").forEach(node -> required.add(node.asText()));
Iterator<Map.Entry<String, JsonNode>> iterator = properties.fields();
while (iterator.hasNext()) {
Map.Entry<String, JsonNode> entry = iterator.next();
JsonNode definition = entry.getValue();
fields.add(new FieldDefinition(tool.name(), "JSON_SCHEMA", direction, "",
entry.getKey(), definition.path("type").asText("object"),
required.contains(entry.getKey()), definition.path("description").asText(""),
constraints(definition), relative(path)));
}
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,
List<FieldDefinition> fields, List<String> diagnostics) {
if (path == null) {

View File

@@ -4,6 +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.dap.report.config.ReportProperties;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.io.IOException;
@@ -13,6 +16,7 @@ import java.util.ArrayList;
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 org.springframework.stereotype.Component;
@@ -22,6 +26,7 @@ import org.springframework.stereotype.Component;
public class ToolSourceDiscovery {
private final Path sourceRoot;
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
public ToolSourceDiscovery(ReportProperties properties) {
String configuredRoot = properties.sourceRoot();
@@ -45,15 +50,89 @@ public class ToolSourceDiscovery {
} catch (IOException exception) {
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
}
return tools.stream()
Map<String, ToolSummary> discovered = tools.stream()
.sorted(Comparator.comparing(ToolSummary::sourceFile))
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
(first, duplicate) -> first, LinkedHashMap::new))
.values().stream()
(first, duplicate) -> first, LinkedHashMap::new));
discoverDefinitions().forEach((name, definition) ->
discovered.merge(name, definition, this::mergeDefinition));
return discovered.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);
}
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('\\', '/');
}
private Path resolveProjectRoot(Path configuredPath) {
Path candidate = configuredPath;
while (candidate != null) {
@@ -88,7 +167,8 @@ public class ToolSourceDiscovery {
}
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "ToolHint").orElse(null);
AnnotationExpr hint = JavaAnnotationReader.find(method.getAnnotations(), "GrowToolHint")
.or(() -> JavaAnnotationReader.find(method.getAnnotations(), "ToolHint")).orElse(null);
AnnotationExpr annotations = JavaAnnotationReader.nested(tool, "annotations").orElse(null);
String name = JavaAnnotationReader.string(tool, "name", method.getNameAsString());
String title = JavaAnnotationReader.string(tool, "title", name);

View File

@@ -1,5 +1,5 @@
server:
address: ${REPORT_SERVER_ADDRESS:127.0.0.1}
address: ${REPORT_SERVER_ADDRESS:0.0.0.0}
port: ${REPORT_SERVER_PORT:8092}
spring:

View File

@@ -21,8 +21,9 @@ class ToolReportExcelWriterTest {
byte[] content = new ToolReportExcelWriter().write(List.of(new ToolReportModel(tool, List.of(field), List.of())));
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
assertThat(workbook.getNumberOfSheets()).isEqualTo(4);
assertThat(workbook.getSheet("툴 기본정보").getRow(8).getCell(1).getStringCellValue()).isEqualTo(tool.name());
assertThat(workbook.getSheet("툴 정의").getRow(8).getCell(1).getStringCellValue()).isEqualTo(tool.name());
assertThat(workbook.getSheet("수집 필드").getRow(8).getCell(5).getStringCellValue()).isEqualTo("customerNo");
assertThat(workbook.getSheet("툴 기본정보").getRow(0).getCell(0).getCellStyle().getFillForegroundColorColor().getARGBHex())
.endsWith("262626");

View File

@@ -16,6 +16,36 @@ class ToolSourceDiscoveryTest {
assertThat(discovery.discover())
.extracting(tool -> tool.name())
.contains("oth.cmm.customer.detail", "sms.sms.msg.send");
.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");
});
}
@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"))
.findFirst().orElseThrow();
var report = new ToolDetailAnalyzer(discovery).analyze(tool);
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();
});
}
}

View File

@@ -50,6 +50,7 @@
<a href="/chat.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Tester</a>
<a href="/tool-test-console.html" style="color:#ffffff; text-decoration:none;" class="font-semibold">Console</a>
<a href="/tool-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<div class="flex items-center gap-3">