feat: tool report 카테고리 추가
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m29s

This commit is contained in:
juheelee
2026-08-13 16:13:10 +09:00
parent df03037d3f
commit 206095a7a8
26 changed files with 1212 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
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;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Gateway 내부 MCP 목록 조회와 Report 서비스의 Excel API 프록시를 담당한다. */
@RestController
public class ToolReportProxyController {
private final RestClient restClient;
private final String reportServiceUrl;
private final McpRouterController mcpRouterController;
public ToolReportProxyController(
RestClient.Builder restClientBuilder,
McpRouterController mcpRouterController,
@Value("${report.service-url:http://127.0.0.1:8092}") String reportServiceUrl) {
this.restClient = restClientBuilder.build();
this.mcpRouterController = mcpRouterController;
this.reportServiceUrl = reportServiceUrl.replaceAll("/+$", "");
}
/** Tool 목록은 Report 서비스나 로컬 소스가 아니라 Gateway의 MCP Registry에서만 조회한다. */
@GetMapping(value = "/report/api/report-tools", produces = MediaType.APPLICATION_JSON_VALUE)
public List<ReportToolSummary> tools() {
return activeTools().stream().map(ReportToolSummary::from).toList();
}
@PostMapping(value = "/report/api/tool-reports/excel",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public ResponseEntity<byte[]> excel(@RequestBody Map<String, Object> request) {
validateMcpSelection(request);
ResponseEntity<byte[]> response = restClient.post()
.uri(reportServiceUrl + "/api/tool-reports/excel")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.toEntity(byte[].class);
return copy(response);
}
private void validateMcpSelection(Map<String, Object> request) {
Object rawNames = request.get("toolNames");
if (!(rawNames instanceof List<?> names) || names.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "toolNames must contain at least one tool");
}
Set<String> registeredNames = activeTools().stream()
.map(ToolMetadata::getName)
.collect(java.util.stream.Collectors.toSet());
Set<String> unknown = new LinkedHashSet<>();
names.stream().map(String::valueOf)
.filter(name -> !registeredNames.contains(name))
.forEach(unknown::add);
if (!unknown.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"MCP에 등록되지 않은 툴이 포함되어 있습니다: " + unknown);
}
}
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();
}
private ResponseEntity<byte[]> copy(ResponseEntity<byte[]> response) {
ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode());
MediaType contentType = response.getHeaders().getContentType();
if (contentType != null) builder.contentType(contentType);
String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (disposition != null) builder.header(HttpHeaders.CONTENT_DISPOSITION, disposition);
return builder.body(response.getBody());
}
public record ReportToolSummary(
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) {
static ReportToolSummary from(ToolMetadata tool) {
String name = tool.getName() == null || tool.getName().isBlank() ? tool.getUid() : tool.getName();
String title = tool.getDisplayName() == null || tool.getDisplayName().isBlank()
? name : tool.getDisplayName();
return new ReportToolSummary(
name, title, value(tool.getDescription()), value(tool.getCategoryKey()),
value(tool.getMciServiceId()), Boolean.TRUE.equals(tool.getIsRegistered()),
Boolean.TRUE.equals(tool.getRequiresApproval()), Boolean.TRUE.equals(tool.getReadOnlyHint()),
Boolean.TRUE.equals(tool.getDestructiveHint()), Boolean.TRUE.equals(tool.getIdempotentHint()),
Boolean.TRUE.equals(tool.getOpenWorldHint()), "", "", "", "", "", "");
}
private static String value(String value) {
return value == null ? "" : value;
}
}
}

View File

@@ -72,6 +72,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a> <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="/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-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> </nav>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">

View File

@@ -59,6 +59,7 @@
<a href="/chat.html" style="color:#ffffff;" class="font-semibold">Chat</a> <a href="/chat.html" style="color:#ffffff;" class="font-semibold">Chat</a>
<a href="/tester.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Tester</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-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> </nav>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">

View File

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

View File

@@ -114,6 +114,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a> <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="/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-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> </nav>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">

View File

@@ -77,6 +77,7 @@
<a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a> <a href="/chat.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Chat</a>
<a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a> <a href="/tester.html" style="color:#ffffff;" class="font-semibold">Tester</a>
<a href="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</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> </nav>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">

View File

@@ -0,0 +1,199 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AXHUB Tool Report</title>
<style>
:root {
color-scheme: dark;
font-family: "Geist", "Pretendard", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #09090b;
color: #f4f4f5;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #09090b; color: #f4f4f5; overflow-y: scroll; }
a { color: inherit; text-decoration: none; }
header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid #27272a; background: rgba(9,9,11,.86); backdrop-filter: blur(16px); }
.nav-shell { max-width: 1152px; height: 56px; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; }
.nav-left, nav { display: flex; align-items: center; }
.brand { display: flex; align-items: center; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
.brand-dot { width: 8px; height: 8px; margin-right: 8px; border-radius: 999px; background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,.8); }
.divider { width: 1px; height: 16px; margin: 0 20px; background: #27272a; }
nav { gap: 20px; font-size: 13px; font-weight: 500; color: #a1a1aa; }
nav a { transition: color .15s ease; white-space: nowrap; }
nav a:hover { color: #fff; }
nav .active { color: #fff; font-weight: 600; }
.version { padding: 4px 8px; border: 1px solid rgba(59,130,246,.2); border-radius: 5px; background: rgba(59,130,246,.1); color: #60a5fa; font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
main { max-width: 1152px; margin: 0 auto; padding: 40px 24px 72px; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
h1 { margin: 0 0 10px; font-size: 30px; line-height: 1.2; letter-spacing: -.035em; }
.subtitle { margin: 0; color: #a1a1aa; font-size: 14px; line-height: 1.65; }
.count-badge { padding: 7px 11px; border: 1px solid #27272a; border-radius: 8px; background: #18181b; color: #a1a1aa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; white-space: nowrap; }
.panel { overflow: hidden; border: 1px solid #27272a; border-radius: 12px; background: #111113; box-shadow: 0 18px 50px rgba(0,0,0,.22); }
.toolbar { display: grid; grid-template-columns: minmax(260px,1fr) 190px auto auto; gap: 10px; padding: 16px; border-bottom: 1px solid #27272a; background: #111113; }
input, select, button { min-height: 40px; border: 1px solid #3f3f46; border-radius: 7px; background: #18181b; color: #e4e4e7; padding: 8px 12px; font: inherit; font-size: 13px; outline: none; }
input::placeholder { color: #71717a; }
input:focus, select:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
button { cursor: pointer; font-weight: 600; transition: border-color .15s, background .15s, color .15s; }
button:hover { border-color: #52525b; background: #27272a; color: #fff; }
button.primary { min-width: 174px; border-color: #2563eb; background: #2563eb; color: #fff; }
button.primary:hover { background: #1d4ed8; }
button:disabled { cursor: not-allowed; opacity: .45; }
.table-wrap { max-height: 570px; overflow: auto; }
table { width: 100%; min-width: 980px; border-collapse: collapse; }
th, td { padding: 13px 15px; border-bottom: 1px solid #27272a; text-align: left; vertical-align: top; }
th { position: sticky; top: 0; z-index: 2; background: #18181b; color: #a1a1aa; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
td { color: #d4d4d8; font-size: 13px; line-height: 1.55; }
tbody tr { transition: background .12s ease; }
tbody tr:hover { background: rgba(39,39,42,.5); }
td:first-child, th:first-child { width: 58px; text-align: center; }
input[type="checkbox"] { width: 16px; height: 16px; min-height: 0; accent-color: #3b82f6; }
code { color: #60a5fa; font-family: "Geist Mono", Consolas, monospace; font-size: 12px; }
.muted { color: #71717a; }
.panel-footer { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px; border-top: 1px solid #27272a; background: #111113; }
#message { color: #a1a1aa; font-size: 13px; }
#message.error { color: #fca5a5; }
.loading-row { padding: 42px !important; color: #71717a; text-align: center !important; }
@media (max-width: 900px) {
.nav-shell { overflow-x: auto; }
.version { display: none; }
nav { gap: 14px; }
.toolbar { grid-template-columns: 1fr 1fr; }
.page-heading { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header>
<div class="nav-shell">
<div class="nav-left">
<a id="gatewayHome" class="brand" href="#"><span class="brand-dot"></span><span>AXHUB Gateway</span></a>
<div class="divider"></div>
<nav>
<a data-gateway-path="/admin/scaffold.html" href="#">Scaffold</a>
<a data-gateway-path="/catalog.html" href="#">Catalog</a>
<a data-gateway-path="/playground.html" href="#">Playground</a>
<a data-gateway-path="/chat.html" href="#">Chat</a>
<a data-gateway-path="/tester.html" href="#">Tester</a>
<a data-gateway-path="/tool-test-console.html" href="#">Console</a>
<a class="active" href="/tool-report.html">Report</a>
</nav>
</div>
<span class="version">v0.0.1</span>
</div>
</header>
<main>
<div class="page-heading">
<div>
<h1>Tool Report</h1>
<p class="subtitle">보고서에 포함할 MCP 툴을 선택하고, 소스 어노테이션과 전문 필드를 동일한 Excel 양식으로 생성합니다.</p>
</div>
<span id="summaryBadge" class="count-badge">Loading tools...</span>
</div>
<section class="panel">
<div class="toolbar">
<input id="keyword" type="search" autocomplete="off" placeholder="툴명, 제목 또는 설명 검색">
<select id="category"><option value="">전체 카테고리</option></select>
<button id="selectAll" type="button">전체 선택</button>
<button id="clearAll" type="button">전체 해제</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>선택</th><th>툴명</th><th>제목</th><th>카테고리</th><th>연계 ID</th><th>설명</th></tr></thead>
<tbody id="tools"><tr><td class="loading-row" colspan="6">툴 목록을 불러오는 중입니다.</td></tr></tbody>
</table>
</div>
<div class="panel-footer">
<span id="message">소스 인덱스를 준비하고 있습니다.</span>
<button id="download" type="button" class="primary" disabled>Excel 보고서 생성</button>
</div>
</section>
</main>
<script>
const state = { tools: [], selected: new Set() };
const el = id => document.getElementById(id);
const gateway = location.origin;
el('gatewayHome').href = gateway + '/index.html';
document.querySelectorAll('[data-gateway-path]').forEach(link => link.href = gateway + link.dataset.gatewayPath);
function filtered() {
const keyword = el('keyword').value.trim().toLowerCase();
const category = el('category').value;
return state.tools.filter(tool => (!category || tool.categoryKey === category) &&
(!keyword || [tool.name, tool.title, tool.description].join(' ').toLowerCase().includes(keyword)));
}
function render() {
const visible = filtered();
el('tools').innerHTML = visible.length ? visible.map(tool => `<tr>
<td><input type="checkbox" aria-label="${escapeHtml(tool.name)} 선택" data-name="${escapeHtml(tool.name)}" ${state.selected.has(tool.name) ? 'checked' : ''}></td>
<td><code>${escapeHtml(tool.name)}</code></td><td>${escapeHtml(tool.title)}</td>
<td>${escapeHtml(tool.categoryKey)}</td><td>${escapeHtml(tool.mappingId || '-')}</td><td class="muted">${escapeHtml(tool.description)}</td>
</tr>`).join('') : '<tr><td class="loading-row" colspan="6">조건에 맞는 툴이 없습니다.</td></tr>';
el('tools').querySelectorAll('input[type=checkbox]').forEach(box => box.addEventListener('change', event => {
event.target.checked ? state.selected.add(event.target.dataset.name) : state.selected.delete(event.target.dataset.name);
updateStatus();
}));
updateStatus();
}
function updateStatus(text) {
el('message').textContent = text || `전체 ${state.tools.length}개 중 ${state.selected.size}개 선택`;
el('message').classList.remove('error');
el('summaryBadge').textContent = `${state.tools.length} tools · ${state.selected.size} selected`;
el('download').disabled = state.selected.size === 0;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
}
async function load() {
try {
const response = await fetch('/report/api/report-tools');
if (!response.ok) throw new Error('툴 목록 조회에 실패했습니다.');
state.tools = await response.json();
[...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);
});
render();
} catch (error) {
el('tools').innerHTML = '<tr><td class="loading-row" colspan="6">툴 목록을 표시할 수 없습니다.</td></tr>';
el('message').textContent = error.message; el('message').classList.add('error');
el('summaryBadge').textContent = 'Load failed';
}
}
el('keyword').addEventListener('input', render);
el('category').addEventListener('change', render);
el('selectAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.add(tool.name)); render(); });
el('clearAll').addEventListener('click', () => { filtered().forEach(tool => state.selected.delete(tool.name)); render(); });
el('download').addEventListener('click', async () => {
el('download').disabled = true; updateStatus('소스를 분석하고 Excel을 생성하는 중입니다.');
try {
const response = await fetch('/report/api/tool-reports/excel', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({toolNames: [...state.selected]})
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || '보고서 생성에 실패했습니다.');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = url; anchor.download = 'tool-report.xlsx'; anchor.click(); URL.revokeObjectURL(url);
updateStatus(`선택한 ${state.selected.size}개 툴의 보고서를 생성했습니다.`);
} catch (error) {
el('message').textContent = error.message; el('message').classList.add('error');
} finally { el('download').disabled = state.selected.size === 0; }
});
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /workspace
COPY . .
RUN chmod +x gradlew \
&& ./gradlew :dap-tool-report:bootJar -x test --no-daemon \
&& find dap-tool-report/build/libs -name '*-SNAPSHOT.jar' ! -name '*-plain.jar' -exec cp {} /workspace/app.jar \;
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
COPY --from=builder /workspace/app.jar app.jar
EXPOSE 8092
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,16 @@
plugins {
id 'org.springframework.boot'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.github.javaparser:javaparser-core:3.26.3'
implementation 'org.apache.poi:poi-ooxml:5.3.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.dap.report;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @package io.shinhanlife.dap.report
* @className ToolReportApplication
* @description 툴 소스를 읽기 전용으로 분석하여 Excel 보고서를 생성하는 독립 애플리케이션
* @author 0986406
* @create 2026.08.07
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.08.07 0986406 최초생성
* </pre>
*/
@SpringBootApplication
public class ToolReportApplication {
public static void main(String[] args) {
SpringApplication.run(ToolReportApplication.class, args);
}
}

View File

@@ -0,0 +1,48 @@
package io.shinhanlife.dap.report.application;
import io.shinhanlife.dap.report.excel.ToolReportExcelWriter;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import io.shinhanlife.dap.report.source.ToolDetailAnalyzer;
import io.shinhanlife.dap.report.source.ToolSourceDiscovery;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
/** Gateway가 선택해 전달한 툴의 원천 분석과 Excel 출력을 담당한다. */
@Service
public class ToolReportService {
private final ToolSourceDiscovery discovery;
private final ToolDetailAnalyzer analyzer;
private final ToolReportExcelWriter excelWriter;
public ToolReportService(ToolSourceDiscovery discovery,
ToolDetailAnalyzer analyzer,
ToolReportExcelWriter excelWriter) {
this.discovery = discovery;
this.analyzer = analyzer;
this.excelWriter = excelWriter;
}
public byte[] createExcel(List<String> selectedNames) {
if (selectedNames == null || selectedNames.isEmpty()) {
throw new IllegalArgumentException("보고서에 포함할 툴을 하나 이상 선택해야 합니다.");
}
Map<String, ToolSummary> sourceTools = new LinkedHashMap<>();
discovery.discover().forEach(tool -> sourceTools.put(tool.name(), tool));
List<String> missingSources = selectedNames.stream()
.filter(name -> !sourceTools.containsKey(name)).distinct().toList();
if (!missingSources.isEmpty()) {
throw new IllegalArgumentException("분석할 원천 소스가 없는 툴입니다: " + missingSources);
}
List<ToolReportModel> reports = selectedNames.stream().distinct()
.map(sourceTools::get)
.map(analyzer::analyze)
.toList();
return excelWriter.write(reports);
}
}

View File

@@ -0,0 +1,10 @@
package io.shinhanlife.dap.report.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/** 보고서 모듈 설정. */
@Configuration
@EnableConfigurationProperties(ReportProperties.class)
public class ReportConfiguration {
}

View File

@@ -0,0 +1,8 @@
package io.shinhanlife.dap.report.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** 보고서 소스 위치와 출력 정책 설정. */
@ConfigurationProperties(prefix = "report")
public record ReportProperties(String sourceRoot, String outputFilenamePrefix) {
}

View File

@@ -0,0 +1,255 @@
package io.shinhanlife.dap.report.excel;
import io.shinhanlife.dap.report.model.FieldDefinition;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.DefaultIndexedColorMap;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Component;
/** 모든 보고서 시트에 동일한 전문 I/O 문서 디자인을 적용한다. */
@Component
public class ToolReportExcelWriter {
private static final int HEADER_ROW = 7;
private static final int FIRST_DATA_ROW = 8;
public byte[] write(List<ToolReportModel> reports) {
try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
Styles styles = new Styles(workbook);
writeSummary(workbook, reports, styles);
writeFields(workbook, reports, styles);
writeDiagnostics(workbook, reports, styles);
workbook.write(output);
return output.toByteArray();
} catch (Exception exception) {
throw new IllegalStateException("Excel 보고서 생성에 실패했습니다.", exception);
}
}
private void writeSummary(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "제목", "설명", "카테고리", "연계 ID", "등록", "승인 필요",
"Read Only", "Destructive", "Idempotent", "Open World", "Request", "Response", "UseCase", "원천 파일"};
Sheet sheet = workbook.createSheet("툴 기본정보");
decorateSheet(sheet, "MCP Tool Catalog Report", "선택 툴 기본정보", 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.title(), tool.description(), tool.categoryKey(),
tool.mappingId(), yn(tool.register()), yn(tool.requiresApproval()), yn(tool.readOnlyHint()),
yn(tool.destructiveHint()), yn(tool.idempotentHint()), yn(tool.openWorldHint()),
tool.requestType(), tool.responseType(), tool.useCaseClass(), tool.sourceFile()};
values(row, values, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
}
finishTable(sheet, rowIndex, headers.length,
new int[]{7, 30, 24, 45, 12, 18, 10, 12, 12, 12, 12, 12, 20, 20, 25, 55});
}
private void writeFields(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "원천 종류", "방향", "소유 타입", "필드명", "데이터 타입",
"필수", "설명", "제약조건 / 어노테이션", "원천 파일"};
Sheet sheet = workbook.createSheet("수집 필드");
decorateSheet(sheet, "Tool Source I/O Report", "어노테이션 · DTO · 전문 필드", reports, headers.length, styles);
header(sheet, headers, styles);
int rowIndex = FIRST_DATA_ROW;
int sequence = 1;
for (ToolReportModel report : reports) {
for (FieldDefinition field : report.fields()) {
Row row = sheet.createRow(rowIndex++);
Object[] values = {sequence++, field.toolName(), field.sourceKind(), field.direction(),
field.ownerType(), field.fieldName(), field.dataType(),
field.required() == null ? "" : yn(field.required()), field.description(),
field.constraints(), field.sourceFile()};
values(row, values, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
row.getCell(2).setCellStyle(styles.bodyCenter);
row.getCell(3).setCellStyle(styles.bodyCenter);
row.getCell(7).setCellStyle(styles.bodyCenter);
}
}
finishTable(sheet, rowIndex, headers.length,
new int[]{7, 30, 16, 11, 24, 24, 18, 9, 45, 55, 60});
}
private void writeDiagnostics(Workbook workbook, List<ToolReportModel> reports, Styles styles) {
String[] headers = {"No.", "툴명", "수준", "진단 내용"};
Sheet sheet = workbook.createSheet("분석 결과");
decorateSheet(sheet, "Tool Analysis Report", "소스 분석 및 정합성 진단", reports, headers.length, styles);
header(sheet, headers, styles);
int rowIndex = FIRST_DATA_ROW;
int sequence = 1;
for (ToolReportModel report : reports) {
if (report.diagnostics().isEmpty()) {
Row row = sheet.createRow(rowIndex++);
values(row, new Object[]{sequence++, report.tool().name(), "정상", "분석 경고 없음"}, styles.body);
row.getCell(0).setCellStyle(styles.bodyCenter);
row.getCell(2).setCellStyle(styles.bodyCenter);
} else {
for (String diagnostic : report.diagnostics()) {
Row row = sheet.createRow(rowIndex++);
values(row, new Object[]{sequence++, report.tool().name(), "경고", diagnostic}, styles.warning);
row.getCell(0).setCellStyle(styles.warningCenter);
row.getCell(2).setCellStyle(styles.warningCenter);
}
}
}
finishTable(sheet, rowIndex, headers.length, new int[]{7, 30, 12, 80});
}
private void decorateSheet(Sheet sheet, String titleText, String reportType,
List<ToolReportModel> reports, int columnCount, Styles styles) {
sheet.setDisplayGridlines(false);
sheet.setAutobreaks(true);
sheet.getPrintSetup().setLandscape(true);
sheet.getPrintSetup().setFitWidth((short) 1);
sheet.getPrintSetup().setFitHeight((short) 0);
sheet.setFitToPage(true);
Row title = sheet.createRow(0);
title.setHeightInPoints(24);
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, columnCount - 1));
Cell titleCell = title.createCell(0);
titleCell.setCellValue(titleText);
titleCell.setCellStyle(styles.title);
metadataRow(sheet, 2, "보고서 구분", reportType, columnCount, styles);
metadataRow(sheet, 3, "선택 툴 수", reports.size(), columnCount, styles);
metadataRow(sheet, 4, "생성 일시",
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), columnCount, styles);
}
private void metadataRow(Sheet sheet, int rowIndex, String label, Object value,
int columnCount, Styles styles) {
Row row = sheet.createRow(rowIndex);
row.setHeightInPoints(19);
int labelEnd = Math.min(1, columnCount - 1);
if (labelEnd > 0) sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, labelEnd));
Cell labelCell = row.createCell(0);
labelCell.setCellValue(label);
labelCell.setCellStyle(styles.metaLabel);
int valueStart = labelEnd + 1;
if (valueStart < columnCount - 1) {
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, valueStart, columnCount - 1));
}
Cell valueCell = row.createCell(valueStart);
if (value instanceof Number number) valueCell.setCellValue(number.doubleValue());
else valueCell.setCellValue(value == null ? "" : String.valueOf(value));
valueCell.setCellStyle(styles.metaValue);
}
private void header(Sheet sheet, String[] headers, Styles styles) {
Row row = sheet.createRow(HEADER_ROW);
row.setHeightInPoints(30);
for (int column = 0; column < headers.length; column++) {
Cell cell = row.createCell(column);
cell.setCellValue(headers[column]);
cell.setCellStyle(styles.header);
}
}
private void finishTable(Sheet sheet, int rowIndex, int columnCount, int[] characterWidths) {
widths(sheet, characterWidths);
sheet.createFreezePane(0, FIRST_DATA_ROW);
sheet.setAutoFilter(new CellRangeAddress(HEADER_ROW, Math.max(HEADER_ROW, rowIndex - 1), 0, columnCount - 1));
sheet.setRepeatingRows(new CellRangeAddress(HEADER_ROW, HEADER_ROW, -1, -1));
}
private void values(Row row, Object[] values, CellStyle style) {
row.setHeightInPoints(19);
for (int column = 0; column < values.length; column++) {
Cell cell = row.createCell(column);
Object value = values[column];
if (value instanceof Number number) cell.setCellValue(number.doubleValue());
else cell.setCellValue(value == null ? "" : String.valueOf(value));
cell.setCellStyle(style);
}
}
private void widths(Sheet sheet, int[] characterWidths) {
for (int i = 0; i < characterWidths.length; i++) {
sheet.setColumnWidth(i, Math.min(255, characterWidths[i]) * 256);
}
}
private String yn(boolean value) {
return value ? "Y" : "N";
}
private static final class Styles {
private final CellStyle title;
private final CellStyle metaLabel;
private final CellStyle metaValue;
private final CellStyle header;
private final CellStyle body;
private final CellStyle bodyCenter;
private final CellStyle warning;
private final CellStyle warningCenter;
private Styles(Workbook workbook) {
title = style(workbook, "#262626", "#FFFFFF", 12, true, HorizontalAlignment.CENTER, false);
metaLabel = style(workbook, "#333333", "#FFFFFF", 10, true, HorizontalAlignment.CENTER, false);
metaValue = style(workbook, "#FFFFFF", "#222222", 10, false, HorizontalAlignment.LEFT, false);
header = style(workbook, "#404040", "#FFFFFF", 9, true, HorizontalAlignment.CENTER, true);
body = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.LEFT, true);
bodyCenter = style(workbook, "#FFFFFF", "#222222", 9, false, HorizontalAlignment.CENTER, true);
warning = style(workbook, "#FFF2CC", "#7F6000", 9, false, HorizontalAlignment.LEFT, true);
warningCenter = style(workbook, "#FFF2CC", "#7F6000", 9, true, HorizontalAlignment.CENTER, true);
}
private CellStyle style(Workbook workbook, String fill, String fontColor, int size, boolean bold,
HorizontalAlignment alignment, boolean borders) {
XSSFCellStyle style = (XSSFCellStyle) workbook.createCellStyle();
style.setFillForegroundColor(color(fill));
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setAlignment(alignment);
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setWrapText(true);
Font font = workbook.createFont();
font.setFontName("Carlito");
font.setFontHeightInPoints((short) size);
font.setBold(bold);
((org.apache.poi.xssf.usermodel.XSSFFont) font).setColor(color(fontColor));
style.setFont(font);
if (borders) applyBorders(style);
return style;
}
private void applyBorders(CellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
short borderColor = org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex();
style.setTopBorderColor(borderColor);
style.setBottomBorderColor(borderColor);
style.setLeftBorderColor(borderColor);
style.setRightBorderColor(borderColor);
}
private XSSFColor color(String hex) {
return new XSSFColor(Color.decode(hex), new DefaultIndexedColorMap());
}
}
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.report.model;
/** DTO, JSON Schema 또는 원천 전문에서 수집한 필드 정의. */
public record FieldDefinition(
String toolName,
String sourceKind,
String direction,
String ownerType,
String fieldName,
String dataType,
Boolean required,
String description,
String constraints,
String sourceFile) {
}

View File

@@ -0,0 +1,7 @@
package io.shinhanlife.dap.report.model;
import java.util.List;
/** 수집 단계와 Excel 출력 단계를 분리하는 표준 중간 모델. */
public record ToolReportModel(ToolSummary tool, List<FieldDefinition> fields, List<String> diagnostics) {
}

View File

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

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.report.model;
/** UI 선택 목록과 보고서 기본정보에 사용하는 툴 요약. */
public record 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) {
}

View File

@@ -0,0 +1,54 @@
package io.shinhanlife.dap.report.presentation;
import io.shinhanlife.dap.report.application.ToolReportService;
import io.shinhanlife.dap.report.config.ReportProperties;
import io.shinhanlife.dap.report.model.ToolReportRequest;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import org.springframework.http.ContentDisposition;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 툴 선택 목록과 Excel 다운로드 API. */
@RestController
@RequestMapping("/api")
public class ToolReportController {
private final ToolReportService service;
private final ReportProperties properties;
public ToolReportController(ToolReportService service, ReportProperties properties) {
this.service = service;
this.properties = properties;
}
@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());
String prefix = properties.outputFilenamePrefix() == null || properties.outputFilenamePrefix().isBlank()
? "tool-report" : properties.outputFilenamePrefix();
String filename = prefix + "-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
ContentDisposition disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.contentLength(content.length)
.body(content);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> badRequest(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
}
}

View File

@@ -0,0 +1,55 @@
package io.shinhanlife.dap.report.source;
import com.github.javaparser.ast.NodeList;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MemberValuePair;
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;
/** JavaParser AST에서 어노테이션 값을 안전하게 읽는 도우미. */
final class JavaAnnotationReader {
private JavaAnnotationReader() {
}
static Optional<AnnotationExpr> find(NodeList<AnnotationExpr> annotations, String simpleName) {
return annotations.stream().filter(a -> a.getName().getIdentifier().equals(simpleName)).findFirst();
}
static Optional<Expression> value(AnnotationExpr annotation, String key) {
if (annotation instanceof NormalAnnotationExpr normal) {
return normal.getPairs().stream()
.filter(pair -> pair.getNameAsString().equals(key))
.map(MemberValuePair::getValue)
.findFirst();
}
if (annotation instanceof SingleMemberAnnotationExpr single && "value".equals(key)) {
return Optional.of(single.getMemberValue());
}
return Optional.empty();
}
static String string(AnnotationExpr annotation, String key, String fallback) {
return value(annotation, key)
.filter(StringLiteralExpr.class::isInstance)
.map(StringLiteralExpr.class::cast)
.map(StringLiteralExpr::asString)
.orElse(fallback);
}
static boolean bool(AnnotationExpr annotation, String key, boolean fallback) {
return value(annotation, key)
.filter(BooleanLiteralExpr.class::isInstance)
.map(BooleanLiteralExpr.class::cast)
.map(BooleanLiteralExpr::getValue)
.orElse(fallback);
}
static Optional<AnnotationExpr> nested(AnnotationExpr annotation, String key) {
return value(annotation, key).filter(AnnotationExpr.class::isInstance).map(AnnotationExpr.class::cast);
}
}

View File

@@ -0,0 +1,156 @@
package io.shinhanlife.dap.report.source;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import io.shinhanlife.dap.report.model.FieldDefinition;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.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와 원천 전문 필드를 수집한다. */
@Component
public class ToolDetailAnalyzer {
private final ToolSourceDiscovery discovery;
private final ObjectMapper objectMapper;
public ToolDetailAnalyzer(ToolSourceDiscovery discovery) {
this.discovery = discovery;
this.objectMapper = new ObjectMapper();
}
public ToolReportModel analyze(ToolSummary tool) {
List<FieldDefinition> fields = new ArrayList<>();
List<String> diagnostics = new ArrayList<>();
Map<String, Path> javaFiles = indexJavaFiles();
collectSchema(tool, tool.inputSchemaResource(), "INPUT", fields, diagnostics);
collectSchema(tool, tool.outputSchemaResource(), "OUTPUT", fields, diagnostics);
if (tool.inputSchemaResource().isBlank()) {
collectJavaType(tool, javaFiles.get(tool.requestType()), "DTO", "INPUT", fields, diagnostics);
}
if (tool.outputSchemaResource().isBlank()) {
collectJavaType(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);
}
return new ToolReportModel(tool, List.copyOf(fields), List.copyOf(diagnostics));
}
private Map<String, Path> indexJavaFiles() {
Map<String, Path> result = new LinkedHashMap<>();
try (var paths = Files.walk(discovery.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);
}
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());
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)));
}
} catch (Exception exception) {
diagnostics.add("Schema 분석 실패: " + relative(path));
}
}
private void collectJavaType(ToolSummary tool, Path path, String sourceKind, String direction,
List<FieldDefinition> fields, List<String> diagnostics) {
if (path == null) {
diagnostics.add(sourceKind + " " + direction + " 타입을 찾을 수 없음");
return;
}
try {
CompilationUnit unit = StaticJavaParser.parse(path);
for (TypeDeclaration<?> type : unit.getTypes()) {
collectFields(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));
}
} catch (Exception exception) {
diagnostics.add(sourceKind + " 분석 실패: " + relative(path));
}
}
private void collectFields(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);
AnnotationExpr telegram = JavaAnnotationReader.find(declaration.getAnnotations(), "GlowTrgmField").orElse(null);
declaration.getVariables().forEach(variable -> fields.add(new FieldDefinition(
tool.name(), sourceKind, direction, owner.getNameAsString(), variable.getNameAsString(),
variable.getTypeAsString(),
param == null ? null : JavaAnnotationReader.bool(param, "required", false),
param == null ? "" : JavaAnnotationReader.string(param, "description", ""),
annotationConstraints(schema, telegram), relative(path))));
}
}
private String annotationConstraints(AnnotationExpr schema, AnnotationExpr telegram) {
List<String> values = new ArrayList<>();
if (schema != null) values.add("Schema=" + schema);
if (telegram != null) values.add("GlowTrgmField=" + telegram);
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('\\', '/');
}
}

View File

@@ -0,0 +1,116 @@
package io.shinhanlife.dap.report.source;
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 io.shinhanlife.dap.report.config.ReportProperties;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
/** 프로젝트의 @McpTool 선언을 읽기 전용으로 탐색한다. */
@Component
public class ToolSourceDiscovery {
private final Path sourceRoot;
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());
}
public Path sourceRoot() {
return sourceRoot;
}
public List<ToolSummary> discover() {
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));
} catch (IOException exception) {
throw new IllegalStateException("Failed to scan tool sources: " + sourceRoot, exception);
}
return tools.stream()
.sorted(Comparator.comparing(ToolSummary::sourceFile))
.collect(Collectors.toMap(ToolSummary::name, Function.identity(),
(first, duplicate) -> first, LinkedHashMap::new))
.values().stream()
.sorted(Comparator.comparing(ToolSummary::name))
.toList();
}
private Path resolveProjectRoot(Path configuredPath) {
Path candidate = configuredPath;
while (candidate != null) {
if (Files.isRegularFile(candidate.resolve("settings.gradle"))
&& Files.isDirectory(candidate.resolve("dap-was-lib"))) {
return candidate;
}
candidate = candidate.getParent();
}
return configuredPath;
}
private boolean isUseCaseSource(Path path) {
String normalized = path.toString().replace('\\', '/');
return Files.isRegularFile(path)
&& path.getFileName().toString().endsWith("UseCase.java")
&& normalized.contains("/src/main/java/")
&& !normalized.contains("/dap-tool-report/");
}
private void parse(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)));
}
} catch (Exception exception) {
throw new IllegalStateException("Failed to parse tool source: " + path, exception);
}
}
private ToolSummary toSummary(Path path, String owner, MethodDeclaration method, AnnotationExpr tool) {
AnnotationExpr hint = 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);
String description = JavaAnnotationReader.string(tool, "description", "");
String requestType = method.getParameters().isEmpty() ? "" : method.getParameter(0).getTypeAsString();
return new ToolSummary(
name,
title,
description,
hint == null ? "common" : JavaAnnotationReader.string(hint, "categoryKey", "com"),
hint == null ? "" : JavaAnnotationReader.string(hint, "mappingId", ""),
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, "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", ""));
}
}

View File

@@ -0,0 +1,11 @@
server:
address: ${REPORT_SERVER_ADDRESS:127.0.0.1}
port: ${REPORT_SERVER_PORT:8092}
spring:
application:
name: dap-tool-report
report:
source-root: ${REPORT_SOURCE_ROOT:.}
output-filename-prefix: tool-report

View File

@@ -0,0 +1,31 @@
package io.shinhanlife.dap.report.excel;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dap.report.model.FieldDefinition;
import io.shinhanlife.dap.report.model.ToolReportModel;
import io.shinhanlife.dap.report.model.ToolSummary;
import java.io.ByteArrayInputStream;
import java.util.List;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
class ToolReportExcelWriterTest {
@Test
void writesFixedWorkbookStructure() throws Exception {
ToolSummary tool = new ToolSummary("oth.cst.customer.detail", "고객 상세", "설명", "cst", "ONCSC1340",
false, false, true, false, false, true, "Request", "Response", "UseCase", "UseCase.java", "", "");
FieldDefinition field = new FieldDefinition(tool.name(), "TELEGRAM", "INPUT", "ONCSC1340_I",
"customerNo", "String", true, "고객번호", "length=12", "ONCSC1340_I.java");
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.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

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.report.source;
import static org.assertj.core.api.Assertions.assertThat;
import io.shinhanlife.dap.report.config.ReportProperties;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class ToolSourceDiscoveryTest {
@Test
void discoversMcpToolsFromProjectSources() {
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
ToolSourceDiscovery discovery = new ToolSourceDiscovery(
new ReportProperties(projectRoot.toString(), "tool-report"));
assertThat(discovery.discover())
.extracting(tool -> tool.name())
.contains("oth.cmm.customer.detail", "sms.sms.msg.send");
}
}

View File

@@ -4,3 +4,4 @@ include 'dap-gateway'
include 'dap-was-lib' include 'dap-was-lib'
include 'dap-was-sms' include 'dap-was-sms'
include 'dap-was-oth' include 'dap-was-oth'
include 'dap-tool-report'