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="/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

@@ -59,6 +59,7 @@
<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="/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

@@ -103,6 +103,7 @@
<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="/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>
</div>
<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="/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 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="/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-report.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Report</a>
</nav>
</div>
<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>