feat(ui): add tool-test-console and tester dashboards
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m51s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m51s
- Add Auto-Tester Dashboard (tester.html) in gateway for batch testing tools - Add standalone Tool Test Console (tool-test-console.html) in core - Fix Tailwind CSS Preflight conflicts in console UI - Update console payload schema resolution to support both gateway and pod modes - Strip JSON-RPC metadata from tool payload output in console - Add unified navigation headers across all static HTML pages
This commit is contained in:
16
README.md
16
README.md
@@ -588,3 +588,19 @@ oth.smp.weather.inquiry
|
|||||||
```
|
```
|
||||||
|
|
||||||
When Scaffold receives `dap-tool-oth`, `cmm`, and `ClaimSearch`, it generates `oth.cmm.claim.search`. The `validateMcpToolNames` Gradle task rejects both a duplicate name and any name outside this format before packaging, including its source file and line number.
|
When Scaffold receives `dap-tool-oth`, `cmm`, and `ClaimSearch`, it generates `oth.cmm.claim.search`. The `validateMcpToolNames` Gradle task rejects both a duplicate name and any name outside this format before packaging, including its source file and line number.
|
||||||
|
|
||||||
|
### Tool Test Console
|
||||||
|
|
||||||
|
각 Tool Pod는 공통 테스트 화면을 제공합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8084/tool-test-console.html
|
||||||
|
```
|
||||||
|
|
||||||
|
화면은 현재 Pod의 `/tool-manifest`에서 Tool 목록과 `inputSchema`를 읽습니다. Tool을 선택한 뒤 `Schema 샘플 채우기`로 요청 JSON을 만들고 실행할 수 있습니다. 업무에 맞게 보정한 요청은 `현재 요청 저장`으로 브라우저의 `localStorage`에 보관합니다.
|
||||||
|
|
||||||
|
`Run saved cases`는 저장된 테스트 케이스를 순차 실행해 성공/실패, HTTP 상태, 소요 시간을 보여줍니다. 따라서 Tool이 수백 개여도 각 Tool마다 테스트 화면을 만들 필요 없이, 유효한 업무 테스트 데이터만 한 번 저장하면 이후에는 몇 번의 클릭으로 회귀 테스트할 수 있습니다.
|
||||||
|
|
||||||
|
- Tool 호출은 현재 Pod의 `/mcp/{toolName}`로 수행합니다.
|
||||||
|
- 매 실행마다 `trace-id`, `request-id`를 새로 생성하여 응답과 함께 표시합니다.
|
||||||
|
- 외부 MCI/EAI Tool은 샘플값 대신 개발계에서 허용된 테스트 데이터를 저장해서 사용해야 합니다.
|
||||||
|
|||||||
@@ -381,6 +381,8 @@
|
|||||||
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
||||||
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
|
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
|
||||||
<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="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
<a href="/catalog.html" style="color:#ffffff;" class="font-semibold">Catalog</a>
|
<a href="/catalog.html" style="color:#ffffff;" class="font-semibold">Catalog</a>
|
||||||
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
|
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
|
||||||
<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="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
||||||
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
|
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</a>
|
||||||
<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="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -112,6 +112,8 @@
|
|||||||
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
||||||
<a href="/playground.html" style="color:#ffffff;" class="font-semibold">Playground</a>
|
<a href="/playground.html" style="color:#ffffff;" class="font-semibold">Playground</a>
|
||||||
<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="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
|
|||||||
347
dap-gateway/src/main/resources/static/tester.html
Normal file
347
dap-gateway/src/main/resources/static/tester.html
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tool Auto Tester</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { font-family: 'Geist', sans-serif; }
|
||||||
|
body { background-color: #09090b; color: #f4f4f5; }
|
||||||
|
.geist-mono { font-family: 'Geist Mono', monospace; }
|
||||||
|
|
||||||
|
.btn-execute {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
.btn-execute:hover { box-shadow: 0 0 30px rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
|
||||||
|
.btn-execute:disabled { background: #27272a; color: #52525b; box-shadow: none; transform: none; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
background: #27272a;
|
||||||
|
color: #d4d4d8;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #3f3f46;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { background: #3f3f46; color: white; }
|
||||||
|
.btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.card { background: #18181b; border: 1px solid #3f3f46; border-radius: 12px; }
|
||||||
|
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.table th { text-align: left; padding: 12px 16px; border-bottom: 1px solid #3f3f46; color: #a1a1aa; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.table td { padding: 12px 16px; border-bottom: 1px solid #27272a; vertical-align: middle; }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.table tr:hover td { background-color: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.status-idle { background: rgba(113, 113, 122, 0.15); color: #a1a1aa; border: 1px solid rgba(113, 113, 122, 0.3); }
|
||||||
|
.status-running { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||||
|
.status-success { background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.3); }
|
||||||
|
.status-error { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||||||
|
|
||||||
|
.cat-badge {
|
||||||
|
font-family: 'Geist Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #27272a;
|
||||||
|
color: #a1a1aa;
|
||||||
|
border: 1px solid #3f3f46;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen" style="overflow-y: scroll;">
|
||||||
|
|
||||||
|
<header style="border-bottom: 1px solid #27272a; background: rgba(9,9,11,0.85); backdrop-filter: blur(16px);" class="sticky top-0 z-50">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<a href="/index.html" class="flex items-center group">
|
||||||
|
<div class="w-2 h-2 rounded-full mr-2" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
|
||||||
|
<span class="font-semibold tracking-tight text-sm" style="color:#f4f4f5;">AXHUB Gateway</span>
|
||||||
|
</a>
|
||||||
|
<div class="h-4 w-px" style="background:#27272a;"></div>
|
||||||
|
<nav class="flex space-x-5 text-[13px] font-medium">
|
||||||
|
<a href="/admin/scaffold.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Scaffold</a>
|
||||||
|
<a href="/catalog.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Catalog</a>
|
||||||
|
<a href="/playground.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Playground</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="/tool-test-console.html" style="color:#a1a1aa;" class="hover:text-white transition-colors">Console</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-[10px] uppercase tracking-widest px-2 py-1 rounded font-bold" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">v0.0.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-6 py-10">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight mb-2" style="color:#f4f4f5;">Auto-Tester Dashboard</h1>
|
||||||
|
<p class="text-sm" style="color:#a1a1aa;">Batch execute all registered tools with auto-generated dummy data to verify stability.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button id="exportCsvBtn" class="btn-sm flex items-center gap-2 h-10 px-4 text-sm" onclick="exportCsv()" style="display: none;">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button id="runAllBtn" class="btn-execute flex items-center gap-2" onclick="runAllTests()">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
Run All Tests
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="bg-[#09090b] px-6 py-4 border-b border-[#27272a] flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-slate-300">Tool List</div>
|
||||||
|
<div class="text-xs text-slate-500 geist-mono" id="progressInfo">Total: 0 / Completed: 0</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table" id="toolsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:10%">Category</th>
|
||||||
|
<th style="width:25%">Tool Name</th>
|
||||||
|
<th style="width:25%">Auto-Generated Payload</th>
|
||||||
|
<th style="width:20%">Status</th>
|
||||||
|
<th style="width:20%" class="text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="toolsTbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-zinc-500 py-8">Loading tools...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allTools = [];
|
||||||
|
let isRunningAll = false;
|
||||||
|
|
||||||
|
async function fetchTools() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/mcp/api/v1/tools/list');
|
||||||
|
const data = await response.json();
|
||||||
|
allTools = data.result?.tools || [];
|
||||||
|
renderTable();
|
||||||
|
updateProgress(0);
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('toolsTbody').innerHTML = `<tr><td colspan="5" class="text-center text-red-400 py-8">Failed to load tools. Gateway might be down.</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDummyPayload(schema) {
|
||||||
|
if (!schema || !schema.properties) return {};
|
||||||
|
const payload = {};
|
||||||
|
for (const [key, value] of Object.entries(schema.properties)) {
|
||||||
|
if (value.example !== undefined) {
|
||||||
|
payload[key] = value.example;
|
||||||
|
} else if (value.examples && Array.isArray(value.examples) && value.examples.length > 0) {
|
||||||
|
payload[key] = value.examples[0];
|
||||||
|
} else if (value.default !== undefined) {
|
||||||
|
payload[key] = value.default;
|
||||||
|
} else {
|
||||||
|
if (value.type === 'string') {
|
||||||
|
if (value.enum && value.enum.length > 0) payload[key] = value.enum[0];
|
||||||
|
else payload[key] = "test_string";
|
||||||
|
} else if (value.type === 'integer' || value.type === 'number') {
|
||||||
|
payload[key] = value.minimum !== undefined ? value.minimum : 1;
|
||||||
|
} else if (value.type === 'boolean') {
|
||||||
|
payload[key] = true;
|
||||||
|
} else {
|
||||||
|
payload[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = document.getElementById('toolsTbody');
|
||||||
|
if (allTools.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-zinc-500 py-8">No tools found.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allTools.sort((a, b) => (a.categoryKey || '').localeCompare(b.categoryKey || ''));
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
allTools.forEach((tool, index) => {
|
||||||
|
const payload = generateDummyPayload(tool.parametersSchema);
|
||||||
|
const payloadStr = JSON.stringify(payload);
|
||||||
|
const shortPayload = payloadStr.length > 30 ? payloadStr.substring(0, 30) + '...' : payloadStr;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr id="row-${index}">
|
||||||
|
<td><span class="cat-badge">${tool.categoryKey || 'oth'}</span></td>
|
||||||
|
<td class="font-medium text-slate-200">${tool.name}</td>
|
||||||
|
<td class="text-xs text-zinc-400 geist-mono" title='${payloadStr}'>${shortPayload}</td>
|
||||||
|
<td id="status-${index}"><span class="status-badge status-idle geist-mono">IDLE</span></td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button class="btn-sm run-single-btn" onclick="runSingleTest(${index})">Test Single</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(completed) {
|
||||||
|
document.getElementById('progressInfo').textContent = `Total: ${allTools.length} / Completed: ${completed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCall(toolName, payload, index) {
|
||||||
|
const statusCell = document.getElementById(`status-${index}`);
|
||||||
|
statusCell.innerHTML = `<span class="status-badge status-running geist-mono">RUNNING...</span>`;
|
||||||
|
|
||||||
|
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: toolName, arguments: payload }, id: Date.now() };
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/mcp/api/v1/tools/call', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Agent-Id': 'AUTO-TESTER' },
|
||||||
|
body: JSON.stringify(reqPayload)
|
||||||
|
});
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
allTools[index]._latency = latency;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// API layer might return 200 OK but contain an error message in MCP format.
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
let respData = data;
|
||||||
|
if (data?.result?.result) {
|
||||||
|
respData = data.result.result.data !== undefined ? data.result.result.data : data.result.result;
|
||||||
|
} else if (data?.error) {
|
||||||
|
respData = data.error;
|
||||||
|
}
|
||||||
|
allTools[index]._responseData = respData;
|
||||||
|
|
||||||
|
if (data.result && data.result.isError) {
|
||||||
|
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">ERR: MCP ERROR · ${latency}ms</span>`;
|
||||||
|
allTools[index]._lastStatus = "ERR: MCP ERROR";
|
||||||
|
} else {
|
||||||
|
statusCell.innerHTML = `<span class="status-badge status-success geist-mono">200 OK · ${latency}ms</span>`;
|
||||||
|
allTools[index]._lastStatus = "200 OK";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">HTTP ${response.status} · ${latency}ms</span>`;
|
||||||
|
allTools[index]._lastStatus = `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusCell.innerHTML = `<span class="status-badge status-error geist-mono">NET ERR</span>`;
|
||||||
|
allTools[index]._lastStatus = "NET ERR";
|
||||||
|
allTools[index]._latency = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSingleTest(index) {
|
||||||
|
const tool = allTools[index];
|
||||||
|
const payload = generateDummyPayload(tool.parametersSchema);
|
||||||
|
const btn = document.querySelector(`#row-${index} .run-single-btn`);
|
||||||
|
btn.disabled = true;
|
||||||
|
await executeCall(tool.name, payload, index);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllTests() {
|
||||||
|
if (isRunningAll) return;
|
||||||
|
isRunningAll = true;
|
||||||
|
|
||||||
|
const btn = document.getElementById('runAllBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<div class="spinner border-[2px]" style="width:16px;height:16px;border-top-color:white;margin-right:8px;"></div> Running...`;
|
||||||
|
|
||||||
|
// Reset statuses
|
||||||
|
for (let i = 0; i < allTools.length; i++) {
|
||||||
|
document.getElementById(`status-${i}`).innerHTML = `<span class="status-badge status-idle geist-mono">IDLE</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
// Execute in batches to avoid overwhelming the gateway/services
|
||||||
|
const batchSize = 3;
|
||||||
|
for (let i = 0; i < allTools.length; i += batchSize) {
|
||||||
|
const batch = allTools.slice(i, i + batchSize);
|
||||||
|
const promises = batch.map((tool, idx) => {
|
||||||
|
const actualIndex = i + idx;
|
||||||
|
const payload = generateDummyPayload(tool.parametersSchema);
|
||||||
|
return executeCall(tool.name, payload, actualIndex).then(() => {
|
||||||
|
completed++;
|
||||||
|
updateProgress(completed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunningAll = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> Run All Tests`;
|
||||||
|
|
||||||
|
// Show Export CSV button after all tests run
|
||||||
|
document.getElementById('exportCsvBtn').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
if (allTools.length === 0) return;
|
||||||
|
|
||||||
|
let csvContent = "Category,Tool Name,Status,Latency(ms),Payload,Response\n";
|
||||||
|
|
||||||
|
allTools.forEach(tool => {
|
||||||
|
const category = `"${(tool.categoryKey || 'oth').replace(/"/g, '""')}"`;
|
||||||
|
const name = `"${(tool.name || '').replace(/"/g, '""')}"`;
|
||||||
|
const status = `"${(tool._lastStatus || 'NOT RUN').replace(/"/g, '""')}"`;
|
||||||
|
const latency = tool._latency || 0;
|
||||||
|
|
||||||
|
const payload = generateDummyPayload(tool.parametersSchema);
|
||||||
|
const payloadStr = `"${JSON.stringify(payload).replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
const resp = tool._responseData ? JSON.stringify(tool._responseData) : "";
|
||||||
|
const responseStr = `"${resp.replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
csvContent += `${category},${name},${status},${latency},${payloadStr},${responseStr}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", `mcp_tools_test_report_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', fetchTools);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -12,6 +12,7 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -21,6 +22,7 @@ import org.springframework.stereotype.Service;
|
|||||||
public class ToolManifestService {
|
public class ToolManifestService {
|
||||||
|
|
||||||
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
|
private static final long DEFAULT_TIMEOUT_MILLIS = 300000L;
|
||||||
|
private static final AtomicLong LAST_ISSUED_REVISION = new AtomicLong();
|
||||||
private final Supplier<List<ToolMetadata>> toolSupplier;
|
private final Supplier<List<ToolMetadata>> toolSupplier;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final McpProperties properties;
|
private final McpProperties properties;
|
||||||
@@ -89,10 +91,9 @@ public class ToolManifestService {
|
|||||||
private synchronized String revision(String bundleId, List<ToolManifestItem> tools) {
|
private synchronized String revision(String bundleId, List<ToolManifestItem> tools) {
|
||||||
String fingerprint = fingerprint(bundleId, tools);
|
String fingerprint = fingerprint(bundleId, tools);
|
||||||
if (!fingerprint.equals(lastFingerprint)) {
|
if (!fingerprint.equals(lastFingerprint)) {
|
||||||
long nextTimestamp = System.currentTimeMillis();
|
long localMinimum = lastRevision == null ? Long.MIN_VALUE : Long.parseLong(lastRevision) + 1;
|
||||||
if (lastRevision != null) {
|
long nextTimestamp = LAST_ISSUED_REVISION.updateAndGet(previous ->
|
||||||
nextTimestamp = Math.max(nextTimestamp, Long.parseLong(lastRevision) + 1);
|
Math.max(Math.max(System.currentTimeMillis(), localMinimum), previous + 1));
|
||||||
}
|
|
||||||
lastFingerprint = fingerprint;
|
lastFingerprint = fingerprint;
|
||||||
lastRevision = Long.toString(nextTimestamp);
|
lastRevision = Long.toString(nextTimestamp);
|
||||||
}
|
}
|
||||||
|
|||||||
260
dap-tool-core/src/main/resources/static/tool-test-console.html
Normal file
260
dap-tool-core/src/main/resources/static/tool-test-console.html
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AX HUB Tool Test Console</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; --bg:#09090b; --surface:#18181b; --surface2:#27272a; --line:#3f3f46; --muted:#a1a1aa; --text:#f4f4f5; --blue:#60a5fa; --green:#34d399; --red:#fb7185; --yellow:#fbbf24; }
|
||||||
|
* { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--text); font-family:Arial,"Malgun Gothic",sans-serif; }
|
||||||
|
header { border-bottom:1px solid var(--line); background:#111113; position:sticky; top:0; z-index:2; } .header-inner { max-width:1280px; min-height:64px; margin:auto; padding:0 24px; display:flex; align-items:center; justify-content:space-between; gap:16px; }
|
||||||
|
h1 { margin:0; font-size:18px; } h2 { font-size:15px; margin:0 0 14px; } p { color:var(--muted); line-height:1.55; }
|
||||||
|
main { max-width:1280px; margin:auto; padding:28px 24px 48px; } .intro { margin-bottom:20px; } .intro h1 { font-size:24px; margin-bottom:8px; }
|
||||||
|
.grid { display:grid; grid-template-columns:340px minmax(0,1fr); gap:18px; } .card { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px; }
|
||||||
|
label { display:block; color:var(--muted); font-size:12px; font-weight:700; margin:0 0 7px; } input, select, textarea { width:100%; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#101012; color:var(--text); font:14px ui-monospace,SFMono-Regular,Consolas,monospace; } textarea { min-height:270px; resize:vertical; line-height:1.55; }
|
||||||
|
select { font-family:Arial,"Malgun Gothic",sans-serif; } .stack { display:grid; gap:12px; } .buttons { display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
button { border:1px solid var(--line); border-radius:8px; padding:9px 12px; color:var(--text); background:var(--surface2); cursor:pointer; font-weight:700; } button:hover { border-color:var(--blue); } button.primary { background:#2563eb; border-color:#3b82f6; } button.danger { color:#fecdd3; } button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
.meta { display:flex; gap:8px; flex-wrap:wrap; margin:0 0 14px; } .badge { border:1px solid var(--line); border-radius:99px; padding:4px 8px; color:var(--muted); font-size:11px; font-family:ui-monospace,monospace; } .badge.ok { color:var(--green); border-color:#166534; } .badge.fail { color:var(--red); border-color:#9f1239; }
|
||||||
|
.notice { border-left:3px solid var(--blue); padding:10px 12px; background:#172554; color:#dbeafe; border-radius:4px; font-size:13px; margin-bottom:16px; } .hidden { display:none !important; }
|
||||||
|
.result { background:#101012; border:1px solid var(--line); border-radius:8px; padding:14px; white-space:pre-wrap; overflow:auto; max-height:520px; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||||
|
.case-list { display:grid; gap:8px; margin-top:12px; } .case-row { display:flex; align-items:center; gap:8px; padding:9px; border:1px solid var(--line); border-radius:8px; } .case-row main { padding:0; margin:0; flex:1; min-width:0; } .case-row strong,.case-row small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .case-row small { color:var(--muted); margin-top:3px; }
|
||||||
|
.run-log { margin-top:12px; font:12px/1.55 ui-monospace,monospace; color:var(--muted); white-space:pre-wrap; max-height:220px; overflow:auto; }
|
||||||
|
.footer-note { margin-top:18px; color:#71717a; font-size:12px; } @media (max-width:900px) { .grid { grid-template-columns:1fr; } .header-inner { padding:0 16px; } main { padding:20px 16px; } }
|
||||||
|
</style>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<header style="border-bottom: 1px solid #27272a; background: rgba(9,9,11,0.85); backdrop-filter: blur(16px);" class="sticky top-0 z-50">
|
||||||
|
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<a href="/index.html" class="flex items-center group" style="text-decoration:none;">
|
||||||
|
<div class="w-2 h-2 rounded-full mr-2" style="background:#3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.8);"></div>
|
||||||
|
<span class="font-semibold tracking-tight text-sm" style="color:#f4f4f5;">AXHUB Gateway</span>
|
||||||
|
</a>
|
||||||
|
<div class="h-4 w-px" style="background:#27272a;"></div>
|
||||||
|
<nav class="flex space-x-5 text-[13px] font-medium">
|
||||||
|
<a href="/admin/scaffold.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Scaffold</a>
|
||||||
|
<a href="/catalog.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Catalog</a>
|
||||||
|
<a href="/playground.html" style="color:#a1a1aa; text-decoration:none;" class="hover:text-white transition-colors">Playground</a>
|
||||||
|
<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>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="badge" id="manifestStatus" style="border:1px solid #3f3f46; border-radius:99px; padding:4px 8px; color:#a1a1aa; font-size:11px; font-family:ui-monospace,monospace;">Manifest loading</span>
|
||||||
|
<span class="text-[10px] uppercase tracking-widest px-2 py-1 rounded font-bold" style="background:rgba(59,130,246,0.1); color:#60a5fa; border:1px solid rgba(59,130,246,0.2);">v0.0.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="intro"><h1>Schema 기반 Tool 테스트</h1><p>Tool을 선택하고 요청 JSON을 확인한 뒤 실행하세요. 검증한 요청은 브라우저에 저장되며, 저장된 케이스 전체를 한 번에 다시 실행할 수 있습니다.</p></section>
|
||||||
|
<div class="grid">
|
||||||
|
<aside class="stack">
|
||||||
|
<section class="card stack"><h2>1. Tool 선택</h2><div><label for="filter">검색</label><input id="filter" placeholder="이름, 설명으로 검색"></div><div><label for="toolSelect">Tool</label><select id="toolSelect" size="12"></select></div><div class="meta" id="toolMeta"></div><div class="buttons"><button id="sampleButton">Schema 샘플 채우기</button><button id="reloadButton">Manifest 새로고침</button></div></section>
|
||||||
|
<section class="card"><h2>저장된 테스트 케이스</h2><div class="buttons"><button class="primary" id="runAllButton">Run saved cases</button><button class="danger" id="clearCasesButton">전체 삭제</button></div><div id="caseList" class="case-list"></div><div id="runLog" class="run-log"></div></section>
|
||||||
|
</aside>
|
||||||
|
<section class="stack">
|
||||||
|
<section class="card"><h2>2. 요청 JSON</h2><div class="notice">필수값과 형식은 Tool의 inputSchema 기준입니다. MCI·외부 연동 Tool은 업무에 맞는 테스트 데이터를 입력한 후 저장하세요.</div><textarea id="arguments" spellcheck="false" aria-label="요청 JSON"></textarea><div class="buttons" style="margin-top:12px"><button id="saveButton">현재 요청 저장</button><button class="primary" id="executeButton">실행</button></div></section>
|
||||||
|
<section class="card"><h2>3. 실행 결과</h2><div class="meta"><span class="badge" id="httpStatus">대기</span><span class="badge" id="latency">-</span><span class="badge" id="traceId">trace-id: -</span><span class="badge" id="requestId">request-id: -</span></div><pre class="result" id="result">Tool을 선택하고 실행하세요.</pre></section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<p class="footer-note">이 화면은 현재 Tool Pod의 <code>/tool-manifest</code>와 <code>/mcp/{toolName}</code>만 사용합니다. 저장된 케이스는 이 브라우저의 localStorage에만 보관됩니다.</p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const STORAGE_KEY = 'axhub.tool-test-console.cases.v1';
|
||||||
|
const state = { tools: [], selected: null, cases: loadCases() };
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const select = $('toolSelect'), args = $('arguments'), result = $('result');
|
||||||
|
|
||||||
|
function loadCases() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch (_) { return []; } }
|
||||||
|
function persistCases() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.cases)); renderCases(); }
|
||||||
|
function requestId() { return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; }
|
||||||
|
function escapeHtml(value) { return String(value).replace(/[&<>]/g, item => ({'&':'&','<':'<','>':'>'}[item])); }
|
||||||
|
|
||||||
|
let isGatewayMode = false;
|
||||||
|
|
||||||
|
async function loadManifest() {
|
||||||
|
$('manifestStatus').textContent = 'Manifest loading';
|
||||||
|
try {
|
||||||
|
let response = await fetch('/tool-manifest', { headers: { 'Cache-Control': 'no-cache' } });
|
||||||
|
|
||||||
|
if (!response.ok && response.status === 404) {
|
||||||
|
// Gateway 환경 감지 및 Fallback 처리
|
||||||
|
response = await fetch('/mcp/api/v1/tools/list');
|
||||||
|
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const rpcData = await response.json();
|
||||||
|
state.tools = rpcData.result?.tools || [];
|
||||||
|
isGatewayMode = true;
|
||||||
|
|
||||||
|
$('manifestStatus').textContent = `${state.tools.length} tools · Gateway Mode`;
|
||||||
|
$('manifestStatus').className = 'badge ok';
|
||||||
|
renderTools();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Manifest HTTP ${response.status}`);
|
||||||
|
const manifest = await response.json();
|
||||||
|
state.tools = (manifest.tools || []).filter(tool => tool._meta?.enabled !== false);
|
||||||
|
$('manifestStatus').textContent = `${state.tools.length} tools · rev ${manifest.revision || '-'}`;
|
||||||
|
$('manifestStatus').className = 'badge ok';
|
||||||
|
renderTools();
|
||||||
|
} catch (error) {
|
||||||
|
$('manifestStatus').textContent = `Load error: ${error.message}`;
|
||||||
|
$('manifestStatus').className = 'badge fail';
|
||||||
|
select.innerHTML = '<option>Tool 목록을 불러오지 못했습니다</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTools() {
|
||||||
|
const query = $('filter').value.trim().toLowerCase();
|
||||||
|
const tools = state.tools.filter(tool => `${tool.name} ${tool.title || ''} ${tool.description || ''}`.toLowerCase().includes(query));
|
||||||
|
select.innerHTML = '';
|
||||||
|
for (const tool of tools) {
|
||||||
|
const option = document.createElement('option'); option.value = tool.name;
|
||||||
|
option.textContent = `${tool.name} — ${tool.title || tool.description || ''}`; select.appendChild(option);
|
||||||
|
}
|
||||||
|
if (tools.length) choose(tools.find(tool => tool.name === state.selected?.name) || tools[0]);
|
||||||
|
else { state.selected = null; $('toolMeta').innerHTML = ''; args.value = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(tool) {
|
||||||
|
state.selected = tool; select.value = tool.name;
|
||||||
|
const meta = tool._meta || {}; const hints = tool.annotations || {};
|
||||||
|
$('toolMeta').innerHTML = [
|
||||||
|
`<span class="badge">${escapeHtml(tool.name)}</span>`,
|
||||||
|
`<span class="badge">timeout ${meta.timeoutMillis || 300000}ms</span>`,
|
||||||
|
hints.readOnlyHint ? '<span class="badge ok">read-only</span>' : '',
|
||||||
|
hints.destructiveHint ? '<span class="badge fail">destructive</span>' : ''
|
||||||
|
].join('');
|
||||||
|
args.value = JSON.stringify(exampleForSchema(tool.inputSchema || tool.parametersSchema || {}), null, 2);
|
||||||
|
result.textContent = tool.description || '설명이 없습니다.';
|
||||||
|
resetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleForSchema(schema) {
|
||||||
|
if (schema.default !== undefined) return schema.default;
|
||||||
|
if (Array.isArray(schema.examples) && schema.examples.length) return schema.examples[0];
|
||||||
|
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
|
||||||
|
if (schema.type === 'object' || schema.properties) {
|
||||||
|
const output = {}; const required = new Set(schema.required || []);
|
||||||
|
for (const [name, property] of Object.entries(schema.properties || {})) {
|
||||||
|
if (required.has(name) || property.default !== undefined || property.examples?.length || property.enum?.length) output[name] = exampleForSchema(property);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
if (schema.type === 'array') return [];
|
||||||
|
if (schema.type === 'integer') return 1;
|
||||||
|
if (schema.type === 'number') return 1.0;
|
||||||
|
if (schema.type === 'boolean') return false;
|
||||||
|
if (schema.format === 'date') return new Date().toISOString().slice(0, 10);
|
||||||
|
if (schema.format === 'date-time') return new Date().toISOString();
|
||||||
|
return 'test-value';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments() { try { const value = JSON.parse(args.value || '{}'); if (value && typeof value === 'object' && !Array.isArray(value)) return value; throw new Error('요청 JSON은 객체여야 합니다.'); } catch (error) { throw new Error(`요청 JSON 오류: ${error.message}`); } }
|
||||||
|
function endpointFor(tool) {
|
||||||
|
const fallback = `/mcp/${encodeURIComponent(tool.name)}`;
|
||||||
|
try { const endpoint = new URL(tool.endpoint || fallback, window.location.origin); return endpoint.origin === window.location.origin ? `${endpoint.pathname}${endpoint.search}` : fallback; } catch (_) { return fallback; }
|
||||||
|
}
|
||||||
|
function resetResult() { $('httpStatus').textContent = '대기'; $('httpStatus').className = 'badge'; $('latency').textContent = '-'; $('traceId').textContent = 'trace-id: -'; $('requestId').textContent = 'request-id: -'; }
|
||||||
|
|
||||||
|
async function execute(tool = state.selected, body = null) {
|
||||||
|
if (!tool) throw new Error('실행할 Tool을 선택하세요.');
|
||||||
|
const payload = body || parseArguments(); const trace = requestId(), request = requestId(), started = performance.now();
|
||||||
|
$('executeButton').disabled = true; $('httpStatus').textContent = '실행 중'; $('httpStatus').className = 'badge';
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (isGatewayMode) {
|
||||||
|
const reqPayload = { jsonrpc: "2.0", method: "tools/call", params: { name: tool.name, arguments: payload }, id: Date.now() };
|
||||||
|
response = await fetch('/mcp/api/v1/tools/call', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type':'application/json', 'trace-id':trace, 'request-id':request, 'X-Request-Id':request },
|
||||||
|
body: JSON.stringify(reqPayload)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(endpointFor(tool), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type':'application/json', 'trace-id':trace, 'request-id':request, 'X-Request-Id':request },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const text = await response.text(); let data; try { data = text ? JSON.parse(text) : null; } catch (_) { data = text; }
|
||||||
|
|
||||||
|
let isOk = response.ok;
|
||||||
|
let displayStatus = response.status;
|
||||||
|
if (isGatewayMode && isOk && data?.result?.isError) {
|
||||||
|
isOk = false;
|
||||||
|
displayStatus = "MCP ERR";
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - started);
|
||||||
|
$('httpStatus').textContent = `HTTP ${displayStatus}`; $('httpStatus').className = `badge ${isOk ? 'ok' : 'fail'}`;
|
||||||
|
$('latency').textContent = `${elapsed}ms`; $('traceId').textContent = `trace-id: ${response.headers.get('trace-id') || trace}`; $('requestId').textContent = `request-id: ${response.headers.get('request-id') || request}`;
|
||||||
|
let displayData = data;
|
||||||
|
if (isGatewayMode && data && typeof data === 'object') {
|
||||||
|
if (data.result && data.result.result) {
|
||||||
|
displayData = data.result.result.data !== undefined ? data.result.result.data : data.result.result;
|
||||||
|
} else if (data.error) {
|
||||||
|
displayData = data.error;
|
||||||
|
} else if (data.result && data.result.error_message) {
|
||||||
|
displayData = { error: data.result.error_message, code: data.result.error_code };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.textContent = JSON.stringify(displayData, null, 2); return { ok: isOk, status: displayStatus, elapsed, data };
|
||||||
|
} finally { $('executeButton').disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCase() {
|
||||||
|
try {
|
||||||
|
const payload = parseArguments(); const name = window.prompt('테스트 케이스 이름', state.selected.name);
|
||||||
|
if (!name) return;
|
||||||
|
state.cases.push({ id: requestId(), name, toolName: state.selected.name, arguments: payload, savedAt: new Date().toISOString() }); persistCases();
|
||||||
|
} catch (error) { alert(error.message); }
|
||||||
|
}
|
||||||
|
function renderCases() {
|
||||||
|
const list = $('caseList'); list.innerHTML = '';
|
||||||
|
if (!state.cases.length) { list.innerHTML = '<p style="margin:0;color:#a1a1aa;font-size:13px">저장된 케이스가 없습니다.</p>'; return; }
|
||||||
|
for (const item of state.cases) {
|
||||||
|
const row = document.createElement('div'); row.className = 'case-row';
|
||||||
|
row.innerHTML = `<main><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.toolName)}</small></main><button data-run="${item.id}">실행</button><button class="danger" data-delete="${item.id}">삭제</button>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function runSavedCases() {
|
||||||
|
if (!state.cases.length) return alert('저장된 테스트 케이스가 없습니다.');
|
||||||
|
$('runAllButton').disabled = true; $('runLog').textContent = `총 ${state.cases.length}건 실행 시작\n`;
|
||||||
|
let success = 0;
|
||||||
|
for (const item of state.cases) {
|
||||||
|
const tool = state.tools.find(candidate => candidate.name === item.toolName);
|
||||||
|
if (!tool) { $('runLog').textContent += `FAIL ${item.name} · Tool 없음\n`; continue; }
|
||||||
|
try { const execution = await execute(tool, item.arguments); success += execution.ok ? 1 : 0; $('runLog').textContent += `${execution.ok ? 'PASS' : 'FAIL'} ${item.name} · HTTP ${execution.status} · ${execution.elapsed}ms\n`; }
|
||||||
|
catch (error) { $('runLog').textContent += `FAIL ${item.name} · ${error.message}\n`; }
|
||||||
|
}
|
||||||
|
$('runLog').textContent += `완료: ${success}/${state.cases.length} 성공`; $('runAllButton').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener('change', () => choose(state.tools.find(tool => tool.name === select.value)));
|
||||||
|
$('filter').addEventListener('input', renderTools);
|
||||||
|
$('sampleButton').addEventListener('click', () => { if (state.selected) args.value = JSON.stringify(exampleForSchema(state.selected.inputSchema || state.selected.parametersSchema || {}), null, 2); });
|
||||||
|
$('reloadButton').addEventListener('click', loadManifest);
|
||||||
|
$('saveButton').addEventListener('click', saveCase);
|
||||||
|
$('executeButton').addEventListener('click', async () => { try { await execute(); } catch (error) { $('httpStatus').textContent = '입력 오류'; $('httpStatus').className = 'badge fail'; result.textContent = error.message; } });
|
||||||
|
$('runAllButton').addEventListener('click', runSavedCases);
|
||||||
|
$('clearCasesButton').addEventListener('click', () => { if (confirm('저장된 테스트 케이스를 모두 삭제할까요?')) { state.cases = []; persistCases(); } });
|
||||||
|
$('caseList').addEventListener('click', async event => { const id = event.target.dataset.run || event.target.dataset.delete; if (!id) return; const item = state.cases.find(candidate => candidate.id === id); if (event.target.dataset.delete) { state.cases = state.cases.filter(candidate => candidate.id !== id); persistCases(); return; } const tool = state.tools.find(candidate => candidate.name === item.toolName); try { await execute(tool, item.arguments); } catch (error) { result.textContent = error.message; } });
|
||||||
|
renderCases(); loadManifest();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package io.shinhanlife.dap.mcc.presentation;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class ToolTestConsoleResourceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publishesManifestDrivenToolTestConsole() throws Exception {
|
||||||
|
try (InputStream resource = getClass().getResourceAsStream("/static/tool-test-console.html")) {
|
||||||
|
assertNotNull(resource);
|
||||||
|
String html = new String(resource.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertTrue(html.contains("/tool-manifest"));
|
||||||
|
assertTrue(html.contains("Run saved cases"));
|
||||||
|
assertTrue(html.contains("localStorage"));
|
||||||
|
assertTrue(html.contains("request-id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user