delete portal
All checks were successful
Deploy Gateway / deploy (push) Successful in 1m59s

This commit is contained in:
2026-08-20 09:45:53 +09:00
parent 5cfb8a1bca
commit fe7d8243f6
42 changed files with 0 additions and 3032 deletions

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>agent-test-backend</name>
<comment>Project agent-test-backend created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.springframework.ide.eclipse.boot.validation.springbootbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
</projectDescription>

View File

@@ -1,13 +0,0 @@
arguments=
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=
jvm.arguments=
offline.mode=false
override.workspace.settings=false
show.console.view=false
show.executions.view=false

View File

@@ -1,4 +0,0 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.targetPlatform=21
org.eclipse.jdt.core.compiler.compliance=21
org.eclipse.jdt.core.compiler.source=21

View File

@@ -1,2 +0,0 @@
boot.validation.initialized=true
eclipse.preferences.version=1

View File

@@ -1,137 +0,0 @@
# Agent Test Backend / Portal PoC
Browser-based PoC tester and hardcoded Portal Registry screen for:
```text
Browser -> Agent Test Backend -> MCP Server -> Tool Server
```
## Ports
```text
Tool Server http://localhost:9092
MCP Server http://localhost:8080/mcp
Agent Test Backend http://localhost:7070
```
## STS Run
Import this folder as an existing Gradle project:
```text
C:\Users\hyo\Documents\Codex\agent-test-backend
```
Run `com.example.agenttest.AgentTestBackendApplication`.
Default environment:
```text
AGENT_TEST_PORT=7070
MCP_ENDPOINT_URL=http://localhost:8080/mcp
MCP_PROTOCOL_VERSION=2025-11-25
TOOL_MANIFEST_URL=http://localhost:9092/tool-manifest
TOOL_SERVER_API_KEY=tool-server-key
PORTAL_REGISTRY_REVISION=1
PORTAL_ROUTE_KEY=external
TOOL_SERVICE_DOMAIN=http://localhost:9092
```
Open:
```text
http://localhost:7070
```
## Recommended Verification Order
1. Start Tool Server in STS.
2. Start MCP Server with Tool Server environment values.
3. Start Agent Test Backend.
4. Open `http://localhost:7070`.
5. Click `Initialize`, `Tools/List`, then run `Agent Chat` or `Tools/Call`.
## Portal PoC Scope
The current screen intentionally uses hardcoded registry data instead of DB tables:
```text
MCP Route external -> http://localhost:8080/mcp
Tool Service external-tools -> http://localhost:9092/tool-manifest
Mapping external -> external-tools
```
The MCP-facing Portal Registry API is:
```text
GET http://localhost:7070/api/portal/registry/external
```
For this PoC, MCP can use these IntelliJ environment variables after applying
`C:\Users\hyo\Documents\Codex\mcp-portal-registry.patch` to the MCP project:
```text
MCP_DISCOVERY_ENABLED=true
MCP_PORTAL_ENABLED=true
MCP_PORTAL_ROUTE_KEY=external
MCP_PORTAL_REGISTRY_URL=http://localhost:7070/api/portal/registry/external
MCP_REGISTRY_REFRESH_INTERVAL_SECONDS=60
```
`POST /api/agent/chat` is a first Agent Backend skeleton. It currently uses a simple rule-based
planner so it can run without an OpenAI API key:
```text
"서울 날씨 알려줘" -> external.weather_lookup
"달러 환율 알려줘" -> external.exchange_rate
```
Later, replace the rule-based planner with Codex/OpenAI model selection while keeping the same MCP
`tools/list` and `tools/call` boundary.
## Portal Tool Bundle Management
The Portal now manages both seeded pull-discovery bundles through one API and UI flow:
```text
external-tools -> http://localhost:9092/tool-manifest
business-tools -> http://localhost:9090/tool-manifest
```
Bundle APIs:
```text
GET /api/portal/bundles
POST /api/portal/bundles
PUT /api/portal/bundles/{bundleId}
POST /api/portal/bundles/{bundleId}/sync
```
The API key is write-only. Read responses expose only `apiKeyConfigured`, and logs never contain
the key value. Manifest synchronization validates the configured Bundle ID, Tool name prefix,
required metadata objects, and the configured execution endpoint for every mapped Tool.
Business Tool endpoint mappings are owned by the Portal/MCP configuration rather than the
manifest:
```text
business.customer_search -> POST http://localhost:9090/internal/tools/customer-search
business.order_status -> POST http://localhost:9090/internal/tools/order-status
business.ticket_create -> POST http://localhost:9090/internal/tools/ticket-create
```
The Bundle definitions and synchronized snapshots are currently held in memory because this PoC
does not include a database dependency or an existing schema/migration framework. Therefore no DB
migration is required. Restarting the Portal restores the two seeded definitions and clears cached
manifest snapshots. For persistent operation, the `PortalBundleService` store is the boundary to
replace with the project's chosen repository and migration framework.
Start the business Tool server with pull discovery only:
```text
TOOL_AUTO_REGISTER_ENABLED=false
TOOL_HEARTBEAT_ENABLED=false
TOOL_SERVER_PORT=9090
```
After starting the Tool server, open the Portal and click `Sync Manifest` for `business-tools`.

View File

@@ -1,18 +0,0 @@
server:
port: ${AGENT_TEST_PORT:7070}
agent-test:
mcp:
endpoint-url: ${MCP_ENDPOINT_URL:http://localhost:8080/mcp}
protocol-version: ${MCP_PROTOCOL_VERSION:2025-11-25}
tool-server:
manifest-url: ${TOOL_MANIFEST_URL:http://localhost:9092/tool-manifest}
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
portal:
registry-revision: ${PORTAL_REGISTRY_REVISION:1}
route-key: ${PORTAL_ROUTE_KEY:cus}
tool-service-domain: ${TOOL_SERVICE_DOMAIN:http://localhost:9092}
logging:
level:
com.example.agenttest: INFO

View File

@@ -1,625 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AX HUB Portal PoC</title>
<style>
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--line: #d7dde8;
--text: #172033;
--muted: #667085;
--primary: #1d5fd1;
--primary-dark: #164aa5;
--soft: #eef3fb;
--good: #087443;
--bad: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
}
header {
background: #162033;
color: #fff;
padding: 18px 28px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0;
}
.header-meta {
color: #cbd5e1;
font-size: 13px;
text-align: right;
line-height: 1.5;
}
main {
width: min(1320px, calc(100% - 32px));
margin: 18px auto 28px;
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
}
section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
h2 {
margin: 0 0 12px;
font-size: 17px;
letter-spacing: 0;
}
h3 {
margin: 16px 0 8px;
font-size: 14px;
color: var(--muted);
letter-spacing: 0;
}
.stack {
display: grid;
gap: 12px;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fff;
}
.item-title {
font-weight: 700;
margin-bottom: 6px;
}
.kv {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 5px 10px;
font-size: 13px;
color: var(--muted);
word-break: break-word;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e7f6ee;
color: var(--good);
font-size: 12px;
font-weight: 700;
}
label {
display: block;
margin: 10px 0 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
font: inherit;
background: #fff;
color: var(--text);
}
textarea {
resize: vertical;
min-height: 92px;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
}
.chat-box {
min-height: 104px;
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
font-size: 15px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
button {
border: 0;
border-radius: 6px;
padding: 10px 12px;
min-height: 38px;
background: var(--primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
button:hover {
background: var(--primary-dark);
}
button.secondary {
background: #344054;
}
button.secondary:hover {
background: #202939;
}
button.soft {
background: var(--soft);
color: #1f2937;
}
button.soft:hover {
background: #dfe8f6;
}
pre {
margin: 0;
min-height: 300px;
padding: 14px;
overflow: auto;
border-radius: 8px;
background: #101828;
color: #e5e7eb;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.answer {
margin-top: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcff;
min-height: 80px;
line-height: 1.6;
}
.manual-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 960px) {
header,
main,
.two-col,
.manual-grid {
grid-template-columns: 1fr;
}
header {
display: grid;
}
.header-meta {
text-align: left;
}
}
</style>
</head>
<body>
<header>
<div>
<h1>AX HUB Portal PoC</h1>
<div class="header-meta" style="text-align:left">Hardcoded Registry + Agent Backend + MCP Tool Call</div>
</div>
<div class="header-meta" id="config">Loading configuration...</div>
</header>
<main>
<div class="stack">
<section>
<h2>Portal Registry</h2>
<p class="header-meta" style="text-align:left;color:var(--muted)">필요할 때 MCP가 Registry를 다시 확인하도록 revision을 갱신합니다.</p>
<input id="routeKey" value="cus" type="hidden">
<div class="actions">
<button onclick="bumpRevision()">Portal Revision 올리기</button>
<button onclick="openNewBundle()">+ Tool Server 추가</button>
</div>
<details id="bundleEditor" style="margin-top:14px">
<summary><strong>Tool Server 설정</strong></summary>
<div style="margin-top:12px">
<label for="bundleId">Bundle ID</label>
<input id="bundleId" placeholder="business-tools">
<label for="toolServerName">Tool Server 이름</label>
<input id="toolServerName" placeholder="Business Tool Server">
<label for="manifestUrl">Manifest URL</label>
<input id="manifestUrl" placeholder="http://localhost:9090/tool-manifest">
<label for="baseUrl">실행 Base URL</label>
<input id="baseUrl" placeholder="http://localhost:9090">
<label for="namePrefix">Tool name prefix</label>
<input id="namePrefix" placeholder="business.">
<label><input id="bundleEnabled" type="checkbox" checked style="width:auto"> 활성화</label>
<details style="margin-top:12px">
<summary>고급 설정</summary>
<label for="pollInterval">Manifest 조회 주기(초)</label>
<input id="pollInterval" type="number" min="5" value="60">
<label for="apiKey">Tool Server API Key (입력 전용)</label>
<input id="apiKey" type="password" autocomplete="new-password" placeholder="기존 Key를 유지하려면 비워두세요">
<label for="toolEndpoints">Tool 실행 경로 매핑(JSON)</label>
<textarea id="toolEndpoints">{}</textarea>
</details>
<div class="actions">
<button onclick="saveBundle()">저장</button>
<button class="soft" onclick="closeBundleEditor()">취소</button>
</div>
</div>
</details>
</section>
<section>
<details>
<summary><strong>MCP 연결 테스트</strong></summary>
<label for="mcpToolServerFilter">확인할 Tool Server</label>
<select id="mcpToolServerFilter" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
<option value="external.">External Tool Server</option>
<option value="business.">Business Tool Server</option>
<option value="cus">DAP WAS CUS Tool Server</option>
</select>
<div class="actions">
<button onclick="callApi('POST', mcpTestAction('initialize'))">Initialize</button>
<button class="secondary" onclick="callApi('POST', mcpTestAction('initialized'))">Initialized</button>
<button onclick="loadMcpTools()">Tools/List</button>
</div>
<div id="mcpToolList" class="stack" style="margin-top:12px"></div>
</details>
</section>
</div>
<div class="stack">
<section>
<details>
<summary><strong>Agent 테스트</strong></summary>
<label for="message">사용자 요청</label>
<textarea id="message" class="chat-box">서울 날씨 알려줘</textarea>
<div class="actions">
<button onclick="sendChat()">Agent 실행</button>
<button class="soft" onclick="setMessage('서울 날씨 알려줘')">서울 날씨</button>
<button class="soft" onclick="setMessage('부산 날씨 알려줘')">부산 날씨</button>
<button class="soft" onclick="setMessage('달러 환율 알려줘')">달러 환율</button>
<button class="soft" onclick="setMessage('대한민국 공휴일 조회해줘')">공휴일 조회</button>
<button class="soft" onclick="setMessage('서울 좌표 조회해줘')">서울 좌표</button>
<button class="soft" onclick="setMessage('대한민국 국가 정보 조회해줘')">국가 정보</button>
<button class="soft" onclick="setMessage('고객 조회해줘')">고객 조회</button>
<button class="soft" onclick="setMessage('주문 상태 조회해줘')">주문 상태</button>
<button class="soft" onclick="setMessage('고객 문의 티켓 생성해줘')">티켓 생성</button>
<button class="soft" onclick="setMessage('메타 공통코드 조회해줘')">메타 공통코드</button>
<button class="soft" onclick="setMessage('메타 테이블 조회해줘')">메타 테이블</button>
<button class="soft" onclick="setMessage('템플릿 다운로드 URL 알려줘')">템플릿 URL</button>
<button class="soft" onclick="setMessage('SOL 의뢰서 목록 조회해줘')">SOL 목록</button>
<button class="soft" onclick="setMessage('SOL 의뢰서 상세 조회해줘')">SOL 상세</button>
<button class="soft" onclick="setMessage('보험금 청구 처리해줘')">보험금 청구</button>
<button class="soft" onclick="setMessage('가입설계 한도 조회해줘')">가입설계 한도</button>
<button class="soft" onclick="setMessage('CUS 달러 환율 조회해줘')">CUS 환율</button>
<button class="soft" onclick="setMessage('CUS 서울 날씨 조회해줘')">CUS 날씨</button>
<button class="soft" onclick="setMessage('오늘의 명언 알려줘')">오늘의 명언</button>
<button class="soft" onclick="setMessage('TOOL 파트 구성원 조회해줘')">TOOL 구성원</button>
</div>
<div class="answer" id="answer">Agent 응답 대기 중</div>
</details>
</section>
<section>
<details>
<summary><strong>Tool 직접 호출</strong></summary>
<label for="toolPreset">호출할 Tool</label>
<select id="toolPreset" onchange="setToolPreset(this.value)" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
<option value="external.weather_lookup">날씨 조회</option>
<option value="external.exchange_rate">환율 조회</option>
<option value="external.public_holiday_lookup">공휴일 조회</option>
<option value="external.geocoding_lookup">도시 좌표 조회</option>
<option value="external.country_info_lookup">국가 정보 조회</option>
<option value="business.customer_search">고객 검색</option>
<option value="business.order_status">주문 상태 조회</option>
<option value="business.ticket_create">지원 티켓 생성(승인 필요)</option>
<option value="cmm_comcode_lookup">메타 공통코드 조회</option>
<option value="cmm_customer_tool">고객 통합 안내이력 조회</option>
<option value="cmm_meta_table">메타 테이블 조회</option>
<option value="cmm_template_url">템플릿 URL 조회</option>
<option value="sol_request_list">SOL 의뢰서 목록</option>
<option value="sol_request_detail">SOL 의뢰서 상세</option>
<option value="ins_insurance_processor">보험금 청구 처리</option>
<option value="oth_onnba3011_call">가입설계 한도 조회</option>
<option value="smp_exchange_inquiry">CUS 환율 조회</option>
<option value="smp_weather_inquiry">CUS 날씨 조회</option>
<option value="smp_quote_daily">오늘의 명언</option>
<option value="smp_team_list">TOOL 파트 구성원</option>
</select>
<div class="manual-grid">
<div>
<label for="toolName">Tool Name</label>
<input id="toolName" value="external.weather_lookup">
</div>
<div>
<label for="arguments">Arguments JSON</label>
<textarea id="arguments">{
"city": "Seoul",
"timezone": "Asia/Seoul"
}</textarea>
</div>
</div>
<div class="actions">
<button onclick="callTool()">Tools/Call</button>
<button class="soft" onclick="setWeather()">Weather Args</button>
<button class="soft" onclick="setExchange()">Exchange Args</button>
</div>
</details>
</section>
<section>
<details>
<summary><strong>상세 응답 보기</strong></summary>
<pre id="output">Waiting...</pre>
</details>
</section>
</div>
</main>
<script>
const output = document.getElementById("output");
const config = document.getElementById("config");
const answer = document.getElementById("answer");
async function callApi(method, url, body) {
output.textContent = "Requesting...";
try {
const response = await fetch(url, {
method,
headers: {"Content-Type": "application/json"},
body: body ? JSON.stringify(body) : undefined
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
output.textContent = JSON.stringify(data, null, 2);
if (!response.ok) {
throw new Error(data.detail || data.message || data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
output.textContent = JSON.stringify({error: error.message}, null, 2);
throw error;
}
}
async function loadConfig() {
const data = await callApi("GET", "/api/config");
document.getElementById("routeKey").value = data.defaultRouteKey || "cus";
config.innerHTML = `MCP ${data.mcpEndpointUrl}<br>Route MCP ${data.defaultRoutedMcpEndpointUrl}<br>Manifest ${data.toolManifestUrl}`;
}
async function loadRegistry() {
const bundles = await callApi("GET", "/api/portal/bundles");
window.portalBundles = bundles;
}
function openNewBundle() {
clearBundleForm();
document.getElementById("bundleEditor").open = true;
}
function closeBundleEditor() {
document.getElementById("bundleEditor").open = false;
}
function clearBundleForm() {
["bundleId", "toolServerName", "manifestUrl", "baseUrl", "namePrefix", "apiKey"]
.forEach(id => document.getElementById(id).value = "");
document.getElementById("bundleId").readOnly = false;
document.getElementById("pollInterval").value = 60;
document.getElementById("toolEndpoints").value = "{}";
document.getElementById("bundleEnabled").checked = true;
}
async function saveBundle() {
const idInput = document.getElementById("bundleId");
const payload = {
bundleId: idInput.value.trim(),
toolServerName: document.getElementById("toolServerName").value.trim(),
manifestUrl: document.getElementById("manifestUrl").value.trim(),
baseUrl: document.getElementById("baseUrl").value.trim(),
namePrefix: document.getElementById("namePrefix").value.trim(),
enabled: document.getElementById("bundleEnabled").checked,
manifestPollIntervalSeconds: Number(document.getElementById("pollInterval").value),
apiKey: document.getElementById("apiKey").value,
toolEndpoints: JSON.parse(document.getElementById("toolEndpoints").value || "{}")
};
const url = idInput.readOnly
? `/api/portal/bundles/${encodeURIComponent(payload.bundleId)}`
: "/api/portal/bundles";
await callApi(idInput.readOnly ? "PUT" : "POST", url, payload);
await loadRegistry();
closeBundleEditor();
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[char]);
}
function escapeJs(value) {
return String(value).replace(/[\\']/g, "\\$&");
}
async function bumpRevision() {
const data = await callApi("POST", `/api/portal/registry/${selectedRoute()}/revision`);
await loadRegistry();
output.textContent = JSON.stringify({
message: "Portal registry revision bumped",
routeKey: data.routeKey,
registryRevision: data.registryRevision
}, null, 2);
}
function renderGroup(title, rows) {
return `<div><h3>${title}</h3>${rows.map(renderItem).join("")}</div>`;
}
function renderItem(row) {
const title = row.displayName || row.serviceKey || row.routeKey;
const entries = Object.entries(row)
.map(([key, value]) => `<div>${key}</div><div>${value}</div>`)
.join("");
return `<div class="item"><div class="item-title">${title} <span class="badge">${row.status || row.source}</span></div><div class="kv">${entries}</div></div>`;
}
async function sendChat() {
const message = document.getElementById("message").value.trim();
answer.textContent = "Agent is selecting a tool...";
const data = await callApi("POST", "/api/agent/chat", {message});
answer.innerHTML = `<strong>${data.answer}</strong><br><br>Agent route 결정: ${data.routeKey}<br>판단: ${data.routeDecision}<br>MCP: ${data.mcpEndpointUrl}<br>Tool: ${data.selectedTool}<br>인자: ${JSON.stringify(data.arguments)}`;
}
async function callTool() {
const name = document.getElementById("toolName").value.trim();
const args = JSON.parse(document.getElementById("arguments").value);
const routeKey = name.startsWith("business.") ? "business"
: /^(cmm|ins|oth|smp|sol)_/.test(name) ? "cus" : "external";
await callApi("POST", `/api/mcp/${routeKey}/tools/call`, {name, arguments: args});
}
function setToolPreset(name) {
const presets = {
"external.weather_lookup": {city: "Seoul", timezone: "Asia/Seoul"},
"external.exchange_rate": {from: "USD", to: "KRW"},
"external.public_holiday_lookup": {countryCode: "KR", year: new Date().getFullYear()},
"external.geocoding_lookup": {city: "Seoul", language: "ko"},
"external.country_info_lookup": {countryCode: "KR"},
"business.customer_search": {keyword: "C-1001"},
"business.order_status": {orderId: "O-9001"},
"business.ticket_create": {
title: "Portal test ticket",
priority: "normal",
description: "Created from the Portal Tool test screen"
},
"cmm_comcode_lookup": {groupCode: "GRP_COMM_CD", useYn: "Y"},
"cmm_customer_tool": {csNo: "000000000001"},
"cmm_meta_table": {tableName: "TB_CUST_BAS", owner: "DAPADM"},
"cmm_template_url": {templateId: "TPL_001"},
"sol_request_list": {status: "진행중", period: "1개월", target: "나의 업무"},
"sol_request_detail": {srId: "SR-001"},
"ins_insurance_processor": {claimNumber: "CLM20230001", claimAmount: 1500000, claimDate: "2026-08-12"},
"oth_onnba3011_call": {dalScCd: "1", cstSucoRltyCd: "01", csNo: "000000000001"},
"smp_exchange_inquiry": {currencyCode: "USD"},
"smp_weather_inquiry": {city: "서울"},
"smp_quote_daily": {category: "속담"},
"smp_team_list": {teamName: "TOOL"}
};
document.getElementById("toolName").value = name;
document.getElementById("arguments").value = JSON.stringify(presets[name] || {}, null, 2);
}
function selectedRoute() {
const route = document.getElementById("routeKey").value.trim();
return route || "cus";
}
function mcpAction(action) {
return `/api/mcp/${selectedRoute()}/${action}`;
}
async function loadMcpTools() {
const data = await callApi("POST", mcpTestAction("tools/list"));
const prefix = document.getElementById("mcpToolServerFilter").value;
const tools = (((data || {}).body || {}).result || {}).tools || [];
const filtered = prefix === "all" || prefix === "cus"
? tools : tools.filter(tool => tool.name.startsWith(prefix));
const target = document.getElementById("mcpToolList");
target.innerHTML = filtered.length ? filtered.map(tool => `
<div class="item">
<div class="item-title">${escapeHtml(tool.title || tool.name)}</div>
<div class="kv">
<div>name</div><div>${escapeHtml(tool.name)}</div>
<div>설명</div><div>${escapeHtml(tool.description || "-")}</div>
<div>유형</div><div>${tool.annotations && tool.annotations.readOnlyHint ? "READ" : "WRITE"}</div>
</div>
<div class="actions">
<button class="soft" onclick="prepareToolCall('${escapeJs(tool.name)}')">이 Tool 호출</button>
</div>
</div>`).join("") : `<div class="header-meta" style="text-align:left;color:var(--muted)">선택한 서버의 Tool이 없습니다.</div>`;
}
function mcpTestAction(action) {
const filter = document.getElementById("mcpToolServerFilter").value;
const routeKey = filter === "business." ? "business"
: filter === "cus" ? "cus" : "external";
return `/api/mcp/${routeKey}/${action}`;
}
function prepareToolCall(name) {
setToolPreset(name);
document.getElementById("toolPreset").value = name;
document.getElementById("toolPreset").closest("details").open = true;
document.getElementById("toolPreset").scrollIntoView({behavior: "smooth", block: "center"});
}
function setMessage(value) {
document.getElementById("message").value = value;
}
function setWeather() {
document.getElementById("toolName").value = "external.weather_lookup";
document.getElementById("arguments").value = JSON.stringify({
city: "Seoul",
timezone: "Asia/Seoul"
}, null, 2);
}
function setExchange() {
document.getElementById("toolName").value = "external.exchange_rate";
document.getElementById("arguments").value = JSON.stringify({
from: "USD",
to: "KRW"
}, null, 2);
}
loadConfig().then(loadRegistry).catch(error => {
config.textContent = "Configuration load failed";
output.textContent = JSON.stringify({error: error.message}, null, 2);
});
</script>
</body>
</html>

View File

@@ -1,27 +0,0 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.11'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.1.0'
description = 'agent-test-backend'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,15 +0,0 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = 'agent-test-backend'

View File

@@ -1,16 +0,0 @@
package com.example.agenttest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class AgentTestBackendApplication {
public static void main(String[] args) {
SpringApplication.run(AgentTestBackendApplication.class, args);
}
}

View File

@@ -1,16 +0,0 @@
package com.example.agenttest;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "agent-test")
public record AgentTestProperties(Mcp mcp, ToolServer toolServer, Portal portal) {
public record Mcp(String endpointUrl, String protocolVersion) {
}
public record ToolServer(String manifestUrl, String apiKey) {
}
public record Portal(long registryRevision, String routeKey, String toolServiceDomain) {
}
}

View File

@@ -1,476 +0,0 @@
package com.example.agenttest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.time.Year;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
@RestController
@RequestMapping("/api")
public class McpProxyController {
private static final Logger log = LoggerFactory.getLogger(McpProxyController.class);
private static final String MCP_SESSION_ID_HEADER = "Mcp-Session-Id";
private static final String MCP_PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version";
private static final String TOOL_SERVER_API_KEY_HEADER = "X-Tool-Server-API-Key";
private final AgentTestProperties properties;
private final ObjectMapper objectMapper;
private final RestClient restClient;
private final AtomicLong ids = new AtomicLong(1);
private final PortalBundleService portalBundles;
private final AtomicReference<String> latestSessionId = new AtomicReference<>();
public McpProxyController(
AgentTestProperties properties,
ObjectMapper objectMapper,
RestClient.Builder builder,
PortalBundleService portalBundles) {
this.properties = properties;
this.objectMapper = objectMapper;
this.restClient = builder.build();
this.portalBundles = portalBundles;
}
@GetMapping("/config")
public Map<String, Object> config() {
Map<String, Object> config = new LinkedHashMap<>();
config.put("mcpEndpointUrl", properties.mcp().endpointUrl());
config.put("mcpProtocolVersion", properties.mcp().protocolVersion());
config.put("toolManifestUrl", properties.toolServer().manifestUrl());
config.put("defaultRouteKey", defaultRouteKey());
config.put("defaultRoutedMcpEndpointUrl", mcpEndpointUrl(defaultRouteKey()));
config.put("latestSessionId", latestSessionId.get());
return config;
}
@GetMapping("/registry")
public Map<String, Object> registry() {
String routeKey = defaultRouteKey();
return portalBundles.screenRegistry(routeKey, mcpEndpointUrl(routeKey));
}
@GetMapping("/portal/registry")
public Map<String, Object> portalRegistry(HttpServletRequest request) {
log.info("Inbound portal aggregate registry request: method={}, uri={}, remoteAddress={}, userAgent={}, accept={}",
request.getMethod(), request.getRequestURI(), request.getRemoteAddr(),
request.getHeader("User-Agent"), request.getHeader("Accept"));
Map<String, Object> registry = portalBundles.portalRegistry();
log.info("Portal aggregate registry response: registryRevision={}, routes={}",
registry.get("registryRevision"), registry.get("routes"));
return registry;
}
@GetMapping("/portal/registry/{routeKey}")
public Map<String, Object> portalRegistry(
@PathVariable("routeKey") String routeKey,
HttpServletRequest request) {
log.info("Inbound portal registry request: method={}, uri={}, remoteAddress={}, userAgent={}, accept={}",
request.getMethod(), request.getRequestURI(), request.getRemoteAddr(),
request.getHeader("User-Agent"), request.getHeader("Accept"));
Map<String, Object> registry = portalBundles.portalRegistry(routeKey);
log.info("Portal registry response: routeKey={}, registryRevision={}, toolServices={}",
routeKey, registry.get("registryRevision"), registry.get("toolServices"));
return registry;
}
@PostMapping("/portal/registry/{routeKey}/revision")
public Map<String, Object> bumpPortalRegistryRevision(@PathVariable("routeKey") String routeKey) {
long nextRevision = portalBundles.bumpRevision();
log.info("Portal registry revision changed: routeKey={}, registryRevision={}", routeKey, nextRevision);
return Map.of(
"routeKey", routeKey,
"registryRevision", nextRevision);
}
@GetMapping("/tool-manifest")
public Map<String, Object> toolManifest() {
log.info("Outbound tool-server request: method=GET, uri={}, headers={{{}={}}}",
properties.toolServer().manifestUrl(), TOOL_SERVER_API_KEY_HEADER, masked());
ResponseEntity<JsonNode> response = restClient.get()
.uri(properties.toolServer().manifestUrl())
.header(TOOL_SERVER_API_KEY_HEADER, properties.toolServer().apiKey())
.retrieve()
.toEntity(JsonNode.class);
log.info("Inbound tool-server response: status={}, body={}",
response.getStatusCode().value(), response.getBody());
return response("tool-manifest", response);
}
@PostMapping("/mcp/initialize")
public Map<String, Object> initialize() {
return initialize(properties.portal().routeKey());
}
@PostMapping("/mcp/{routeKey}/initialize")
public Map<String, Object> initialize(@PathVariable("routeKey") String routeKey) {
Map<String, Object> params = Map.of(
"protocolVersion", properties.mcp().protocolVersion(),
"capabilities", Map.of(),
"clientInfo", Map.of(
"name", "agent-test-backend",
"version", "0.1.0"));
ResponseEntity<JsonNode> response = postMcp(routeKey, jsonRpc("initialize", params), false);
String sessionId = response.getHeaders().getFirst(MCP_SESSION_ID_HEADER);
if (sessionId != null && !sessionId.isBlank()) {
latestSessionId.set(sessionId);
}
return response("initialize", response);
}
@PostMapping("/mcp/initialized")
public Map<String, Object> initialized() {
return initialized(properties.portal().routeKey());
}
@PostMapping("/mcp/{routeKey}/initialized")
public Map<String, Object> initialized(@PathVariable("routeKey") String routeKey) {
ResponseEntity<JsonNode> response = postMcp(routeKey, notification("notifications/initialized"), true);
return response("notifications/initialized", response);
}
@PostMapping("/mcp/tools/list")
public Map<String, Object> toolsList() {
return toolsList(properties.portal().routeKey());
}
@PostMapping("/mcp/{routeKey}/tools/list")
public Map<String, Object> toolsList(@PathVariable("routeKey") String routeKey) {
ResponseEntity<JsonNode> response = postMcp(routeKey, jsonRpc("tools/list", Map.of()), true);
return response("tools/list", response);
}
@PostMapping("/mcp/tools/call")
public Map<String, Object> toolsCall(@RequestBody ToolCallRequest request) {
return callTool(routeKeyForTool(request.name()), request.name(), request.arguments() == null ? Map.of() : request.arguments());
}
@PostMapping("/mcp/{routeKey}/tools/call")
public Map<String, Object> toolsCall(
@PathVariable("routeKey") String routeKey,
@RequestBody ToolCallRequest request) {
return callTool(routeKey, request.name(), request.arguments() == null ? Map.of() : request.arguments());
}
@PostMapping("/agent/chat")
public Map<String, Object> chat(@RequestBody ChatRequest request) {
PlannedTool plannedTool = plan(request.message());
String routeKey = plannedTool.routeKey();
Map<String, Object> toolCall = callTool(routeKey, plannedTool.name(), plannedTool.arguments());
JsonNode body = objectMapper.valueToTree(toolCall.get("body"));
JsonNode toolPayload = firstToolText(body);
if (body.path("result").path("isError").asBoolean(false)) {
String toolError = toolPayload.path("text").asText("도구 호출에 실패했습니다.");
toolPayload = objectMapper.createObjectNode().put("error", toolError);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("routeKey", routeKey);
result.put("mcpEndpointUrl", mcpEndpointUrl(routeKey));
result.put("message", request.message());
result.put("selectedTool", plannedTool.name());
result.put("routeDecision", "%s prefix Tool은 %s route로 전송".formatted(
toolPrefix(plannedTool.name()), routeKey));
result.put("arguments", plannedTool.arguments());
result.put("answer", answer(plannedTool.name(), toolPayload));
result.put("toolResult", toolPayload);
result.put("rawMcpResponse", toolCall);
return result;
}
private Map<String, Object> callTool(String routeKey, String name, Map<String, Object> arguments) {
Map<String, Object> params = Map.of("name", name, "arguments", arguments);
ResponseEntity<JsonNode> response = postMcp(routeKey, jsonRpc("tools/call", params), true);
return response("tools/call", response);
}
private PlannedTool plan(String message) {
return planRequest(message);
}
static PlannedTool planRequest(String message) {
String normalized = message == null ? "" : message.toLowerCase(Locale.ROOT);
if (normalized.contains("메타 공통코드")) {
return new PlannedTool("cus", "cmm_comcode_lookup",
Map.of("groupCode", "GRP_COMM_CD", "useYn", "Y"));
}
if (normalized.contains("메타 테이블")) {
return new PlannedTool("cus", "cmm_meta_table",
Map.of("tableName", "TB_CUST_BAS", "owner", "DAPADM"));
}
if (normalized.contains("템플릿")) {
return new PlannedTool("cus", "cmm_template_url", Map.of("templateId", "TPL_001"));
}
if (normalized.contains("sol 의뢰서 상세") || normalized.contains("sol 상세")) {
return new PlannedTool("cus", "sol_request_detail", Map.of("srId", "SR-001"));
}
if (normalized.contains("sol 의뢰서") || normalized.contains("sol 목록")) {
return new PlannedTool("cus", "sol_request_list",
Map.of("status", "진행중", "period", "1개월", "target", "나의 업무"));
}
if (normalized.contains("보험금 청구")) {
return new PlannedTool("cus", "ins_insurance_processor", Map.of(
"claimNumber", "CLM20230001", "claimAmount", 1500000, "claimDate", "2026-08-12"));
}
if (normalized.contains("가입설계 한도") || normalized.contains("onnba3011")) {
return new PlannedTool("cus", "oth_onnba3011_call", Map.of(
"dalScCd", "1", "cstSucoRltyCd", "01", "csNo", "000000000001"));
}
if (normalized.contains("cus") && (normalized.contains("환율") || normalized.contains("exchange"))) {
return new PlannedTool("cus", "smp_exchange_inquiry", Map.of("currencyCode", "USD"));
}
if (normalized.contains("cus") && (normalized.contains("날씨") || normalized.contains("weather"))) {
return new PlannedTool("cus", "smp_weather_inquiry", Map.of("city", "서울"));
}
if (normalized.contains("오늘의 명언") || normalized.contains("명언")) {
return new PlannedTool("cus", "smp_quote_daily", Map.of("category", "속담"));
}
if (normalized.contains("tool 파트") || normalized.contains("툴 파트") || normalized.contains("파트 구성원")) {
return new PlannedTool("cus", "smp_team_list", Map.of("teamName", "TOOL"));
}
if (normalized.contains("공휴일") || normalized.contains("휴일") || normalized.contains("holiday")) {
return new PlannedTool("external", "external.public_holiday_lookup", Map.of(
"countryCode", "KR",
"year", Year.now().getValue()));
}
if ((normalized.contains("고객") || normalized.contains("customer"))
&& !normalized.contains("티켓") && !normalized.contains("ticket")) {
return new PlannedTool("business", "business.customer_search", Map.of("keyword", "C-1001"));
}
if (normalized.contains("주문") || normalized.contains("order")) {
return new PlannedTool("business", "business.order_status", Map.of("orderId", "O-9001"));
}
if (normalized.contains("티켓") || normalized.contains("ticket")) {
return new PlannedTool("business", "business.ticket_create", Map.of(
"title", "Portal test ticket",
"priority", "normal",
"description", "Created from the Portal Agent test screen"));
}
if (normalized.contains("좌표") || normalized.contains("지오코딩") || normalized.contains("geocoding")) {
return new PlannedTool("external", "external.geocoding_lookup", Map.of("city", "Seoul", "language", "ko"));
}
if (normalized.contains("국가") || normalized.contains("나라") || normalized.contains("country")) {
return new PlannedTool("external", "external.country_info_lookup", Map.of("countryCode", "KR"));
}
if (normalized.contains("환율") || normalized.contains("달러") || normalized.contains("usd")
|| normalized.contains("exchange")) {
return new PlannedTool("external", "external.exchange_rate", Map.of("from", "USD", "to", "KRW"));
}
return new PlannedTool("external", "external.weather_lookup", Map.of(
"city", city(normalized),
"timezone", "Asia/Seoul"));
}
private static String toolPrefix(String toolName) {
int dot = toolName.indexOf('.');
if (dot > 0) {
return toolName.substring(0, dot);
}
int underscore = toolName.indexOf('_');
return underscore > 0 ? toolName.substring(0, underscore) : toolName;
}
private static String city(String message) {
if (message.contains("부산") || message.contains("busan")) {
return "Busan";
}
if (message.contains("대구") || message.contains("daegu")) {
return "Daegu";
}
if (message.contains("인천") || message.contains("incheon")) {
return "Incheon";
}
return "Seoul";
}
private JsonNode firstToolText(JsonNode mcpBody) {
JsonNode content = mcpBody.path("result").path("content");
if (!content.isArray() || content.isEmpty()) {
return mcpBody;
}
String text = content.get(0).path("text").asText("");
if (text.isBlank()) {
return content.get(0);
}
try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
return objectMapper.createObjectNode().put("text", text);
}
}
static String answer(String toolName, JsonNode payload) {
boolean directToolResult = !payload.has("success") && !payload.has("error");
if (!directToolResult && !payload.path("success").asBoolean(false)) {
String error = payload.path("error").asText();
if (error.isBlank()) {
error = payload.path("text").asText("도구 호출에 실패했습니다.");
}
return "도구 실행이 실패했습니다: " + error;
}
JsonNode data = directToolResult ? payload : payload.path("data");
if ("external.public_holiday_lookup".equals(toolName)) {
JsonNode holidays = data.path("holidays");
List<String> preview = new java.util.ArrayList<>();
if (holidays.isArray()) {
for (int index = 0; index < Math.min(holidays.size(), 5); index++) {
JsonNode holiday = holidays.get(index);
preview.add("%s %s".formatted(
holiday.path("date").asText(),
holiday.path("localName").asText(holiday.path("name").asText())));
}
}
return "%s년 대한민국 공휴일은 총 %s일입니다.%s".formatted(
data.path("year").asText(String.valueOf(Year.now().getValue())),
holidays.isArray() ? holidays.size() : 0,
preview.isEmpty() ? "" : " 주요 공휴일: " + String.join(", ", preview));
}
if (toolName.startsWith("business.")) {
return "%s 실행 결과: %s".formatted(toolName, data.toString());
}
if (isCusTool(toolName)) {
return "%s 실행 결과: %s".formatted(toolName, data.toString());
}
if ("external.geocoding_lookup".equals(toolName)) {
return "도시 좌표 조회 결과: " + data.path("results").toString();
}
if ("external.country_info_lookup".equals(toolName)) {
return "국가 정보 조회 결과: " + data.path("countries").toString();
}
if ("external.exchange_rate".equals(toolName)) {
return "%s 기준 %s/%s 환율은 %s입니다.".formatted(
data.path("date").asText("현재"),
data.path("base").asText("USD"),
data.path("target").asText("KRW"),
data.path("rate").asText());
}
return "%s 현재 기온은 %s도이고 풍속은 %s입니다.".formatted(
data.path("city").asText("해당 지역"),
data.path("temperature").asText(),
data.path("windSpeed").asText());
}
private static boolean isCusTool(String toolName) {
return toolName.startsWith("cmm_") || toolName.startsWith("ins_")
|| toolName.startsWith("oth_") || toolName.startsWith("smp_")
|| toolName.startsWith("sol_");
}
private ResponseEntity<JsonNode> postMcp(String routeKey, Map<String, Object> payload, boolean includeProtocolHeaders) {
String sessionId = includeProtocolHeaders ? latestSessionId.get() : null;
String endpointUrl = mcpEndpointUrl(routeKey);
Map<String, Object> headers = new LinkedHashMap<>();
headers.put("Content-Type", MediaType.APPLICATION_JSON_VALUE);
if (includeProtocolHeaders) {
headers.put(MCP_PROTOCOL_VERSION_HEADER, properties.mcp().protocolVersion());
if (sessionId != null && !sessionId.isBlank()) {
headers.put(MCP_SESSION_ID_HEADER, sessionId);
}
}
log.info("Outbound MCP request: method=POST, uri={}, headers={}, body={}",
endpointUrl, headers, payload);
RestClient.RequestBodySpec spec = restClient.post()
.uri(endpointUrl)
.contentType(MediaType.APPLICATION_JSON);
if (includeProtocolHeaders) {
spec.header(MCP_PROTOCOL_VERSION_HEADER, properties.mcp().protocolVersion());
if (sessionId != null && !sessionId.isBlank()) {
spec.header(MCP_SESSION_ID_HEADER, sessionId);
}
}
ResponseEntity<JsonNode> response = spec.body(payload).retrieve().toEntity(JsonNode.class);
log.info("Inbound MCP response: status={}, headers={{{}={}}}, body={}",
response.getStatusCode().value(), MCP_SESSION_ID_HEADER,
response.getHeaders().getFirst(MCP_SESSION_ID_HEADER), response.getBody());
return response;
}
private String mcpEndpointUrl(String routeKey) {
String baseUrl = properties.mcp().endpointUrl().replaceAll("/+$", "");
String normalizedRouteKey = normalizeRouteKey(routeKey);
return normalizedRouteKey.isBlank() ? baseUrl : baseUrl + "/" + normalizedRouteKey;
}
private String normalizeRouteKey(String routeKey) {
if (routeKey == null || routeKey.isBlank()) {
return defaultRouteKey();
}
return routeKey.trim();
}
private String defaultRouteKey() {
String configured = properties.portal().routeKey();
return configured == null || configured.isBlank() ? "cus" : configured.trim();
}
private String routeKeyForTool(String toolName) {
if (toolName == null || toolName.isBlank()) {
return defaultRouteKey();
}
if (toolName.startsWith("business.")) {
return "business";
}
if (isCusTool(toolName)) {
return "cus";
}
return "external";
}
private String masked() {
return properties.toolServer().apiKey() == null || properties.toolServer().apiKey().isBlank()
? "<empty>"
: "********";
}
private Map<String, Object> jsonRpc(String method, Map<String, Object> params) {
return Map.of(
"jsonrpc", "2.0",
"id", ids.getAndIncrement(),
"method", method,
"params", params);
}
private Map<String, Object> notification(String method) {
return Map.of(
"jsonrpc", "2.0",
"method", method,
"params", Map.of());
}
private Map<String, Object> response(String action, ResponseEntity<JsonNode> response) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("action", action);
result.put("httpStatus", response.getStatusCode().value());
result.put("mcpSessionId", response.getHeaders().getFirst(MCP_SESSION_ID_HEADER));
result.put("body", response.getBody() == null ? objectMapper.createObjectNode() : response.getBody());
return result;
}
public record ToolCallRequest(String name, Map<String, Object> arguments) {
}
public record ChatRequest(String message) {
}
record PlannedTool(String routeKey, String name, Map<String, Object> arguments) {
}
}

View File

@@ -1,21 +0,0 @@
package com.example.agenttest;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
@RestControllerAdvice(assignableTypes = PortalBundleController.class)
public class PortalApiExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handle(ResponseStatusException error) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", error.getStatusCode().value());
body.put("error", error.getStatusCode().toString());
body.put("message", error.getReason() == null ? "Request failed" : error.getReason());
return ResponseEntity.status(error.getStatusCode()).body(body);
}
}

View File

@@ -1,45 +0,0 @@
package com.example.agenttest;
import com.example.agenttest.PortalBundleService.BundleRequest;
import com.example.agenttest.PortalBundleService.BundleView;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/portal/bundles")
public class PortalBundleController {
private final PortalBundleService bundles;
public PortalBundleController(PortalBundleService bundles) {
this.bundles = bundles;
}
@GetMapping
public List<BundleView> list() {
return bundles.list();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public BundleView create(@RequestBody BundleRequest request) {
return bundles.create(request);
}
@PutMapping("/{bundleId}")
public BundleView update(
@PathVariable("bundleId") String bundleId,
@RequestBody BundleRequest request) {
return bundles.update(bundleId, request);
}
}

View File

@@ -1,346 +0,0 @@
package com.example.agenttest;
import java.net.URI;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
@Service
public class PortalBundleService {
private static final Pattern BUNDLE_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}");
private static final Pattern TOOL_NAME = Pattern.compile("[A-Za-z0-9_./-]{1,64}");
private final Map<String, BundleState> bundles = new ConcurrentHashMap<>();
private final AtomicLong registryRevision;
public PortalBundleService(AgentTestProperties properties, RestClient.Builder builder) {
this.registryRevision = new AtomicLong(properties.portal().registryRevision());
String sharedApiKey = properties.toolServer().apiKey();
putSeed(new BundleDefinition(
"external-tools", "External Tool Server",
properties.toolServer().manifestUrl(), properties.portal().toolServiceDomain(),
"external.", true, 10, sharedApiKey,
Map.of(
"external.weather_lookup", "/mcp/external.weather_lookup",
"external.exchange_rate", "/mcp/external.exchange_rate",
"external.public_holiday_lookup", "/mcp/external.public_holiday_lookup",
"external.geocoding_lookup", "/mcp/external.geocoding_lookup",
"external.country_info_lookup", "/mcp/external.country_info_lookup")));
putSeed(new BundleDefinition(
"business-tools", "Business Tool Server",
"http://localhost:9090/tool-manifest", "http://localhost:9090",
"business.", true, 10, sharedApiKey,
Map.of(
"business.customer_search", "/mcp/business.customer_search",
"business.order_status", "/mcp/business.order_status",
"business.ticket_create", "/mcp/business.ticket_create")));
putSeed(new BundleDefinition(
"was-cus", "DAP WAS CUS Tool Server",
"http://localhost:8084/tool-manifest", "http://localhost:8084",
"", true, 10, null,
cusToolEndpoints()));
}
public List<BundleView> list() {
return bundles.values().stream()
.map(BundleState::view)
.sorted(Comparator.comparing(view -> view.definition().bundleId()))
.toList();
}
public BundleView create(BundleRequest request) {
BundleDefinition definition = validated(request, null);
if (bundles.containsKey(definition.bundleId())) {
throw conflict("Bundle ID already exists: " + definition.bundleId());
}
ensureManifestUrlUnique(definition.manifestUrl(), null);
BundleState state = new BundleState(definition);
bundles.put(definition.bundleId(), state);
registryRevision.incrementAndGet();
return state.view();
}
public BundleView update(String bundleId, BundleRequest request) {
BundleState current = required(bundleId);
BundleDefinition definition = validated(request, current.definition().apiKey());
if (!bundleId.equals(definition.bundleId())) {
throw badRequest("Bundle ID cannot be changed");
}
ensureManifestUrlUnique(definition.manifestUrl(), bundleId);
current.update(definition);
registryRevision.incrementAndGet();
return current.view();
}
public Map<String, Object> portalRegistry(String routeKey) {
List<Map<String, Object>> services = bundles.values().stream()
.filter(state -> state.definition().enabled())
.filter(state -> belongsToRoute(state.definition(), routeKey))
.map(state -> service(state.definition()))
.sorted(Comparator.comparing(item -> String.valueOf(item.get("serviceKey"))))
.toList();
return Map.of(
"routeKey", routeKey,
"registryRevision", registryRevision.get(),
"toolServices", services);
}
public Map<String, Object> portalRegistry() {
List<Map<String, Object>> routes = bundles.values().stream()
.filter(state -> state.definition().enabled())
.map(state -> routeKey(state.definition()))
.distinct()
.sorted()
.map(routeKey -> Map.of(
"routeKey", routeKey,
"toolServices", bundles.values().stream()
.filter(state -> state.definition().enabled())
.filter(state -> belongsToRoute(state.definition(), routeKey))
.map(state -> service(state.definition()))
.sorted(Comparator.comparing(item -> String.valueOf(item.get("serviceKey"))))
.toList()))
.toList();
return Map.of(
"registryRevision", registryRevision.get(),
"routes", routes);
}
private boolean belongsToRoute(BundleDefinition definition, String routeKey) {
String normalizedRoute = routeKey == null ? "" : routeKey.trim().toLowerCase(java.util.Locale.ROOT);
return routeKey(definition).equalsIgnoreCase(normalizedRoute);
}
private String routeKey(BundleDefinition definition) {
String prefix = definition.namePrefix();
if (prefix == null || prefix.isBlank()) {
return definition.bundleId().startsWith("was-")
? definition.bundleId().substring("was-".length())
: definition.bundleId();
}
int dot = prefix.indexOf('.');
int underscore = prefix.indexOf('_');
int end = dot >= 0 && underscore >= 0 ? Math.min(dot, underscore) : Math.max(dot, underscore);
return end > 0 ? prefix.substring(0, end) : prefix.replaceAll("[._]+$", "");
}
public Map<String, Object> screenRegistry(String routeKey, String mcpEndpointUrl) {
Map<String, Object> route = new LinkedHashMap<>();
route.put("routeKey", routeKey);
route.put("displayName", "Portal MCP Route");
route.put("routePath", "/mcp/" + routeKey);
route.put("mcpEndpointUrl", mcpEndpointUrl);
route.put("status", "ACTIVE");
List<Map<String, Object>> services = list().stream().map(view -> {
BundlePublicDefinition item = view.definition();
Map<String, Object> service = new LinkedHashMap<>();
service.put("serviceKey", item.bundleId());
service.put("displayName", item.toolServerName());
service.put("manifestUrl", item.manifestUrl());
service.put("baseEndpoint", item.baseUrl());
service.put("namePrefix", item.namePrefix());
service.put("enabled", item.enabled());
service.put("manifestPollIntervalSeconds", item.manifestPollIntervalSeconds());
service.put("status", item.enabled() ? "ACTIVE" : "INACTIVE");
return service;
}).toList();
return Map.of("mcpRoutes", List.of(route), "toolServices", services,
"mappings", services.stream().map(service -> Map.of(
"routeKey", routeKey,
"serviceKey", service.get("serviceKey"),
"status", service.get("status"),
"source", "portal-bundle-registry",
"registryRevision", registryRevision.get())).toList());
}
public long bumpRevision() {
return registryRevision.incrementAndGet();
}
private Map<String, Object> service(BundleDefinition definition) {
URI manifest = URI.create(definition.manifestUrl());
String manifestPath = manifest.getRawPath();
Map<String, Object> service = new LinkedHashMap<>();
service.put("serviceKey", definition.bundleId());
service.put("displayName", definition.toolServerName());
service.put("serviceDomain", definition.baseUrl());
service.put("manifestPath", manifestPath == null || manifestPath.isBlank() ? "/tool-manifest" : manifestPath);
service.put("executeBasePath", "");
service.put("namePrefix", definition.namePrefix());
service.put("toolEndpoints", definition.toolEndpoints());
service.put("status", definition.enabled() ? "ACTIVE" : "INACTIVE");
return service;
}
private BundleDefinition validated(BundleRequest request, String existingApiKey) {
if (request == null) {
throw badRequest("Request body is required");
}
String bundleId = requiredText(request.bundleId(), "bundleId");
if (!BUNDLE_ID.matcher(bundleId).matches()) {
throw badRequest("Bundle ID is invalid");
}
String name = requiredText(request.toolServerName(), "toolServerName");
String manifestUrl = validUrl(request.manifestUrl(), "manifestUrl");
String baseUrl = validUrl(request.baseUrl(), "baseUrl").replaceAll("/+$", "");
String prefix = request.namePrefix() == null ? "" : request.namePrefix().trim();
if (!prefix.isBlank() && !prefix.endsWith(".") && !prefix.endsWith("_")) {
throw badRequest("Tool name prefix must end with '.' or '_'");
}
long interval = request.manifestPollIntervalSeconds();
if (interval < 5 || interval > 86_400) {
throw badRequest("Manifest poll interval must be between 5 and 86400 seconds");
}
Map<String, String> endpoints = request.toolEndpoints() == null
? Map.of() : Map.copyOf(request.toolEndpoints());
endpoints.forEach((toolName, path) -> {
if (!TOOL_NAME.matcher(toolName).matches()
|| (!prefix.isBlank() && !toolName.startsWith(prefix))) {
throw badRequest("Tool endpoint name must start with " + prefix + ": " + toolName);
}
if (path == null || !path.startsWith("/") || path.startsWith("//")) {
throw badRequest("Tool endpoint path must start with a single '/': " + toolName);
}
});
String apiKey = request.apiKey() == null || request.apiKey().isBlank() ? existingApiKey : request.apiKey();
return new BundleDefinition(bundleId, name, manifestUrl, baseUrl, prefix,
request.enabled(), interval, apiKey, endpoints);
}
private Map<String, String> cusToolEndpoints() {
List<String> names = List.of(
"cmm_comcode_lookup", "cmm_customer_tool", "cmm_meta_table", "cmm_template_url",
"ins_insurance_processor", "oth_onnba3011_call",
"smp_exchange_inquiry", "smp_quote_daily", "smp_team_list",
"smp_weather_inquiry", "sol_request_detail", "sol_request_list");
Map<String, String> endpoints = new LinkedHashMap<>();
names.forEach(name -> endpoints.put(name, "/mcp/" + name));
return Map.copyOf(endpoints);
}
private void ensureManifestUrlUnique(String manifestUrl, String excludedBundleId) {
boolean duplicate = bundles.values().stream().anyMatch(state ->
!state.definition().bundleId().equals(excludedBundleId)
&& state.definition().manifestUrl().equalsIgnoreCase(manifestUrl));
if (duplicate) {
throw conflict("Manifest URL already exists: " + manifestUrl);
}
}
private BundleState required(String bundleId) {
BundleState state = bundles.get(bundleId);
if (state == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Bundle not found: " + bundleId);
}
return state;
}
private void putSeed(BundleDefinition definition) {
bundles.put(definition.bundleId(), new BundleState(definition));
}
private String requiredText(String value, String field) {
if (value == null || value.isBlank()) {
throw badRequest(field + " is required");
}
return value.trim();
}
private String validUrl(String value, String field) {
String text = requiredText(value, field);
try {
URI uri = URI.create(text);
if (!uri.isAbsolute() || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|| uri.getHost() == null) {
throw new IllegalArgumentException();
}
return uri.toString();
} catch (RuntimeException error) {
throw badRequest(field + " must be an absolute HTTP(S) URL");
}
}
private ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
public record BundleRequest(
String bundleId,
String toolServerName,
String manifestUrl,
String baseUrl,
String namePrefix,
boolean enabled,
long manifestPollIntervalSeconds,
String apiKey,
Map<String, String> toolEndpoints) {
}
public record BundleDefinition(
String bundleId,
String toolServerName,
String manifestUrl,
String baseUrl,
String namePrefix,
boolean enabled,
long manifestPollIntervalSeconds,
String apiKey,
Map<String, String> toolEndpoints) {
}
public record BundlePublicDefinition(
String bundleId,
String toolServerName,
String manifestUrl,
String baseUrl,
String namePrefix,
boolean enabled,
long manifestPollIntervalSeconds,
boolean apiKeyConfigured,
Map<String, String> toolEndpoints) {
}
public record BundleView(
BundlePublicDefinition definition,
String manifestRevision,
String lastSynchronizedAt,
String lastError,
List<Map<String, Object>> tools) {
}
private static final class BundleState {
private volatile BundleDefinition definition;
private BundleState(BundleDefinition definition) {
this.definition = definition;
}
private synchronized void update(BundleDefinition definition) {
this.definition = definition;
}
private BundleDefinition definition() { return definition; }
private BundleView view() {
BundleDefinition item = definition;
BundlePublicDefinition publicDefinition = new BundlePublicDefinition(
item.bundleId(), item.toolServerName(), item.manifestUrl(), item.baseUrl(),
item.namePrefix(), item.enabled(), item.manifestPollIntervalSeconds(),
item.apiKey() != null && !item.apiKey().isBlank(), item.toolEndpoints());
return new BundleView(publicDefinition, null, null, null, List.of());
}
}
}

View File

@@ -1,18 +0,0 @@
server:
port: ${AGENT_TEST_PORT:7070}
agent-test:
mcp:
endpoint-url: ${MCP_ENDPOINT_URL:http://localhost:8080/mcp}
protocol-version: ${MCP_PROTOCOL_VERSION:2025-11-25}
tool-server:
manifest-url: ${TOOL_MANIFEST_URL:http://localhost:9092/tool-manifest}
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
portal:
registry-revision: ${PORTAL_REGISTRY_REVISION:1}
route-key: ${PORTAL_ROUTE_KEY:cus}
tool-service-domain: ${TOOL_SERVICE_DOMAIN:http://localhost:9092}
logging:
level:
com.example.agenttest: INFO

View File

@@ -1,625 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AX HUB Portal PoC</title>
<style>
:root {
--bg: #f4f6f9;
--surface: #ffffff;
--line: #d7dde8;
--text: #172033;
--muted: #667085;
--primary: #1d5fd1;
--primary-dark: #164aa5;
--soft: #eef3fb;
--good: #087443;
--bad: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
}
header {
background: #162033;
color: #fff;
padding: 18px 28px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
h1 {
margin: 0;
font-size: 22px;
letter-spacing: 0;
}
.header-meta {
color: #cbd5e1;
font-size: 13px;
text-align: right;
line-height: 1.5;
}
main {
width: min(1320px, calc(100% - 32px));
margin: 18px auto 28px;
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 16px;
}
section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
h2 {
margin: 0 0 12px;
font-size: 17px;
letter-spacing: 0;
}
h3 {
margin: 16px 0 8px;
font-size: 14px;
color: var(--muted);
letter-spacing: 0;
}
.stack {
display: grid;
gap: 12px;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fff;
}
.item-title {
font-weight: 700;
margin-bottom: 6px;
}
.kv {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 5px 10px;
font-size: 13px;
color: var(--muted);
word-break: break-word;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e7f6ee;
color: var(--good);
font-size: 12px;
font-weight: 700;
}
label {
display: block;
margin: 10px 0 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px;
font: inherit;
background: #fff;
color: var(--text);
}
textarea {
resize: vertical;
min-height: 92px;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
}
.chat-box {
min-height: 104px;
font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif;
font-size: 15px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
button {
border: 0;
border-radius: 6px;
padding: 10px 12px;
min-height: 38px;
background: var(--primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
button:hover {
background: var(--primary-dark);
}
button.secondary {
background: #344054;
}
button.secondary:hover {
background: #202939;
}
button.soft {
background: var(--soft);
color: #1f2937;
}
button.soft:hover {
background: #dfe8f6;
}
pre {
margin: 0;
min-height: 300px;
padding: 14px;
overflow: auto;
border-radius: 8px;
background: #101828;
color: #e5e7eb;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.answer {
margin-top: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcff;
min-height: 80px;
line-height: 1.6;
}
.manual-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 960px) {
header,
main,
.two-col,
.manual-grid {
grid-template-columns: 1fr;
}
header {
display: grid;
}
.header-meta {
text-align: left;
}
}
</style>
</head>
<body>
<header>
<div>
<h1>AX HUB Portal PoC</h1>
<div class="header-meta" style="text-align:left">Hardcoded Registry + Agent Backend + MCP Tool Call</div>
</div>
<div class="header-meta" id="config">Loading configuration...</div>
</header>
<main>
<div class="stack">
<section>
<h2>Portal Registry</h2>
<p class="header-meta" style="text-align:left;color:var(--muted)">필요할 때 MCP가 Registry를 다시 확인하도록 revision을 갱신합니다.</p>
<input id="routeKey" value="cus" type="hidden">
<div class="actions">
<button onclick="bumpRevision()">Portal Revision 올리기</button>
<button onclick="openNewBundle()">+ Tool Server 추가</button>
</div>
<details id="bundleEditor" style="margin-top:14px">
<summary><strong>Tool Server 설정</strong></summary>
<div style="margin-top:12px">
<label for="bundleId">Bundle ID</label>
<input id="bundleId" placeholder="business-tools">
<label for="toolServerName">Tool Server 이름</label>
<input id="toolServerName" placeholder="Business Tool Server">
<label for="manifestUrl">Manifest URL</label>
<input id="manifestUrl" placeholder="http://localhost:9090/tool-manifest">
<label for="baseUrl">실행 Base URL</label>
<input id="baseUrl" placeholder="http://localhost:9090">
<label for="namePrefix">Tool name prefix</label>
<input id="namePrefix" placeholder="business.">
<label><input id="bundleEnabled" type="checkbox" checked style="width:auto"> 활성화</label>
<details style="margin-top:12px">
<summary>고급 설정</summary>
<label for="pollInterval">Manifest 조회 주기(초)</label>
<input id="pollInterval" type="number" min="5" value="60">
<label for="apiKey">Tool Server API Key (입력 전용)</label>
<input id="apiKey" type="password" autocomplete="new-password" placeholder="기존 Key를 유지하려면 비워두세요">
<label for="toolEndpoints">Tool 실행 경로 매핑(JSON)</label>
<textarea id="toolEndpoints">{}</textarea>
</details>
<div class="actions">
<button onclick="saveBundle()">저장</button>
<button class="soft" onclick="closeBundleEditor()">취소</button>
</div>
</div>
</details>
</section>
<section>
<details>
<summary><strong>MCP 연결 테스트</strong></summary>
<label for="mcpToolServerFilter">확인할 Tool Server</label>
<select id="mcpToolServerFilter" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
<option value="external.">External Tool Server</option>
<option value="business.">Business Tool Server</option>
<option value="cus">DAP WAS CUS Tool Server</option>
</select>
<div class="actions">
<button onclick="callApi('POST', mcpTestAction('initialize'))">Initialize</button>
<button class="secondary" onclick="callApi('POST', mcpTestAction('initialized'))">Initialized</button>
<button onclick="loadMcpTools()">Tools/List</button>
</div>
<div id="mcpToolList" class="stack" style="margin-top:12px"></div>
</details>
</section>
</div>
<div class="stack">
<section>
<details>
<summary><strong>Agent 테스트</strong></summary>
<label for="message">사용자 요청</label>
<textarea id="message" class="chat-box">서울 날씨 알려줘</textarea>
<div class="actions">
<button onclick="sendChat()">Agent 실행</button>
<button class="soft" onclick="setMessage('서울 날씨 알려줘')">서울 날씨</button>
<button class="soft" onclick="setMessage('부산 날씨 알려줘')">부산 날씨</button>
<button class="soft" onclick="setMessage('달러 환율 알려줘')">달러 환율</button>
<button class="soft" onclick="setMessage('대한민국 공휴일 조회해줘')">공휴일 조회</button>
<button class="soft" onclick="setMessage('서울 좌표 조회해줘')">서울 좌표</button>
<button class="soft" onclick="setMessage('대한민국 국가 정보 조회해줘')">국가 정보</button>
<button class="soft" onclick="setMessage('고객 조회해줘')">고객 조회</button>
<button class="soft" onclick="setMessage('주문 상태 조회해줘')">주문 상태</button>
<button class="soft" onclick="setMessage('고객 문의 티켓 생성해줘')">티켓 생성</button>
<button class="soft" onclick="setMessage('메타 공통코드 조회해줘')">메타 공통코드</button>
<button class="soft" onclick="setMessage('메타 테이블 조회해줘')">메타 테이블</button>
<button class="soft" onclick="setMessage('템플릿 다운로드 URL 알려줘')">템플릿 URL</button>
<button class="soft" onclick="setMessage('SOL 의뢰서 목록 조회해줘')">SOL 목록</button>
<button class="soft" onclick="setMessage('SOL 의뢰서 상세 조회해줘')">SOL 상세</button>
<button class="soft" onclick="setMessage('보험금 청구 처리해줘')">보험금 청구</button>
<button class="soft" onclick="setMessage('가입설계 한도 조회해줘')">가입설계 한도</button>
<button class="soft" onclick="setMessage('CUS 달러 환율 조회해줘')">CUS 환율</button>
<button class="soft" onclick="setMessage('CUS 서울 날씨 조회해줘')">CUS 날씨</button>
<button class="soft" onclick="setMessage('오늘의 명언 알려줘')">오늘의 명언</button>
<button class="soft" onclick="setMessage('TOOL 파트 구성원 조회해줘')">TOOL 구성원</button>
</div>
<div class="answer" id="answer">Agent 응답 대기 중</div>
</details>
</section>
<section>
<details>
<summary><strong>Tool 직접 호출</strong></summary>
<label for="toolPreset">호출할 Tool</label>
<select id="toolPreset" onchange="setToolPreset(this.value)" style="width:100%;padding:10px;border:1px solid var(--line);border-radius:6px">
<option value="external.weather_lookup">날씨 조회</option>
<option value="external.exchange_rate">환율 조회</option>
<option value="external.public_holiday_lookup">공휴일 조회</option>
<option value="external.geocoding_lookup">도시 좌표 조회</option>
<option value="external.country_info_lookup">국가 정보 조회</option>
<option value="business.customer_search">고객 검색</option>
<option value="business.order_status">주문 상태 조회</option>
<option value="business.ticket_create">지원 티켓 생성(승인 필요)</option>
<option value="cmm_comcode_lookup">메타 공통코드 조회</option>
<option value="cmm_customer_tool">고객 통합 안내이력 조회</option>
<option value="cmm_meta_table">메타 테이블 조회</option>
<option value="cmm_template_url">템플릿 URL 조회</option>
<option value="sol_request_list">SOL 의뢰서 목록</option>
<option value="sol_request_detail">SOL 의뢰서 상세</option>
<option value="ins_insurance_processor">보험금 청구 처리</option>
<option value="oth_onnba3011_call">가입설계 한도 조회</option>
<option value="smp_exchange_inquiry">CUS 환율 조회</option>
<option value="smp_weather_inquiry">CUS 날씨 조회</option>
<option value="smp_quote_daily">오늘의 명언</option>
<option value="smp_team_list">TOOL 파트 구성원</option>
</select>
<div class="manual-grid">
<div>
<label for="toolName">Tool Name</label>
<input id="toolName" value="external.weather_lookup">
</div>
<div>
<label for="arguments">Arguments JSON</label>
<textarea id="arguments">{
"city": "Seoul",
"timezone": "Asia/Seoul"
}</textarea>
</div>
</div>
<div class="actions">
<button onclick="callTool()">Tools/Call</button>
<button class="soft" onclick="setWeather()">Weather Args</button>
<button class="soft" onclick="setExchange()">Exchange Args</button>
</div>
</details>
</section>
<section>
<details>
<summary><strong>상세 응답 보기</strong></summary>
<pre id="output">Waiting...</pre>
</details>
</section>
</div>
</main>
<script>
const output = document.getElementById("output");
const config = document.getElementById("config");
const answer = document.getElementById("answer");
async function callApi(method, url, body) {
output.textContent = "Requesting...";
try {
const response = await fetch(url, {
method,
headers: {"Content-Type": "application/json"},
body: body ? JSON.stringify(body) : undefined
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
output.textContent = JSON.stringify(data, null, 2);
if (!response.ok) {
throw new Error(data.detail || data.message || data.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
output.textContent = JSON.stringify({error: error.message}, null, 2);
throw error;
}
}
async function loadConfig() {
const data = await callApi("GET", "/api/config");
document.getElementById("routeKey").value = data.defaultRouteKey || "cus";
config.innerHTML = `MCP ${data.mcpEndpointUrl}<br>Route MCP ${data.defaultRoutedMcpEndpointUrl}<br>Manifest ${data.toolManifestUrl}`;
}
async function loadRegistry() {
const bundles = await callApi("GET", "/api/portal/bundles");
window.portalBundles = bundles;
}
function openNewBundle() {
clearBundleForm();
document.getElementById("bundleEditor").open = true;
}
function closeBundleEditor() {
document.getElementById("bundleEditor").open = false;
}
function clearBundleForm() {
["bundleId", "toolServerName", "manifestUrl", "baseUrl", "namePrefix", "apiKey"]
.forEach(id => document.getElementById(id).value = "");
document.getElementById("bundleId").readOnly = false;
document.getElementById("pollInterval").value = 60;
document.getElementById("toolEndpoints").value = "{}";
document.getElementById("bundleEnabled").checked = true;
}
async function saveBundle() {
const idInput = document.getElementById("bundleId");
const payload = {
bundleId: idInput.value.trim(),
toolServerName: document.getElementById("toolServerName").value.trim(),
manifestUrl: document.getElementById("manifestUrl").value.trim(),
baseUrl: document.getElementById("baseUrl").value.trim(),
namePrefix: document.getElementById("namePrefix").value.trim(),
enabled: document.getElementById("bundleEnabled").checked,
manifestPollIntervalSeconds: Number(document.getElementById("pollInterval").value),
apiKey: document.getElementById("apiKey").value,
toolEndpoints: JSON.parse(document.getElementById("toolEndpoints").value || "{}")
};
const url = idInput.readOnly
? `/api/portal/bundles/${encodeURIComponent(payload.bundleId)}`
: "/api/portal/bundles";
await callApi(idInput.readOnly ? "PUT" : "POST", url, payload);
await loadRegistry();
closeBundleEditor();
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[char]);
}
function escapeJs(value) {
return String(value).replace(/[\\']/g, "\\$&");
}
async function bumpRevision() {
const data = await callApi("POST", `/api/portal/registry/${selectedRoute()}/revision`);
await loadRegistry();
output.textContent = JSON.stringify({
message: "Portal registry revision bumped",
routeKey: data.routeKey,
registryRevision: data.registryRevision
}, null, 2);
}
function renderGroup(title, rows) {
return `<div><h3>${title}</h3>${rows.map(renderItem).join("")}</div>`;
}
function renderItem(row) {
const title = row.displayName || row.serviceKey || row.routeKey;
const entries = Object.entries(row)
.map(([key, value]) => `<div>${key}</div><div>${value}</div>`)
.join("");
return `<div class="item"><div class="item-title">${title} <span class="badge">${row.status || row.source}</span></div><div class="kv">${entries}</div></div>`;
}
async function sendChat() {
const message = document.getElementById("message").value.trim();
answer.textContent = "Agent is selecting a tool...";
const data = await callApi("POST", "/api/agent/chat", {message});
answer.innerHTML = `<strong>${data.answer}</strong><br><br>Agent route 결정: ${data.routeKey}<br>판단: ${data.routeDecision}<br>MCP: ${data.mcpEndpointUrl}<br>Tool: ${data.selectedTool}<br>인자: ${JSON.stringify(data.arguments)}`;
}
async function callTool() {
const name = document.getElementById("toolName").value.trim();
const args = JSON.parse(document.getElementById("arguments").value);
const routeKey = name.startsWith("business.") ? "business"
: /^(cmm|ins|oth|smp|sol)_/.test(name) ? "cus" : "external";
await callApi("POST", `/api/mcp/${routeKey}/tools/call`, {name, arguments: args});
}
function setToolPreset(name) {
const presets = {
"external.weather_lookup": {city: "Seoul", timezone: "Asia/Seoul"},
"external.exchange_rate": {from: "USD", to: "KRW"},
"external.public_holiday_lookup": {countryCode: "KR", year: new Date().getFullYear()},
"external.geocoding_lookup": {city: "Seoul", language: "ko"},
"external.country_info_lookup": {countryCode: "KR"},
"business.customer_search": {keyword: "C-1001"},
"business.order_status": {orderId: "O-9001"},
"business.ticket_create": {
title: "Portal test ticket",
priority: "normal",
description: "Created from the Portal Tool test screen"
},
"cmm_comcode_lookup": {groupCode: "GRP_COMM_CD", useYn: "Y"},
"cmm_customer_tool": {csNo: "000000000001"},
"cmm_meta_table": {tableName: "TB_CUST_BAS", owner: "DAPADM"},
"cmm_template_url": {templateId: "TPL_001"},
"sol_request_list": {status: "진행중", period: "1개월", target: "나의 업무"},
"sol_request_detail": {srId: "SR-001"},
"ins_insurance_processor": {claimNumber: "CLM20230001", claimAmount: 1500000, claimDate: "2026-08-12"},
"oth_onnba3011_call": {dalScCd: "1", cstSucoRltyCd: "01", csNo: "000000000001"},
"smp_exchange_inquiry": {currencyCode: "USD"},
"smp_weather_inquiry": {city: "서울"},
"smp_quote_daily": {category: "속담"},
"smp_team_list": {teamName: "TOOL"}
};
document.getElementById("toolName").value = name;
document.getElementById("arguments").value = JSON.stringify(presets[name] || {}, null, 2);
}
function selectedRoute() {
const route = document.getElementById("routeKey").value.trim();
return route || "cus";
}
function mcpAction(action) {
return `/api/mcp/${selectedRoute()}/${action}`;
}
async function loadMcpTools() {
const data = await callApi("POST", mcpTestAction("tools/list"));
const prefix = document.getElementById("mcpToolServerFilter").value;
const tools = (((data || {}).body || {}).result || {}).tools || [];
const filtered = prefix === "all" || prefix === "cus"
? tools : tools.filter(tool => tool.name.startsWith(prefix));
const target = document.getElementById("mcpToolList");
target.innerHTML = filtered.length ? filtered.map(tool => `
<div class="item">
<div class="item-title">${escapeHtml(tool.title || tool.name)}</div>
<div class="kv">
<div>name</div><div>${escapeHtml(tool.name)}</div>
<div>설명</div><div>${escapeHtml(tool.description || "-")}</div>
<div>유형</div><div>${tool.annotations && tool.annotations.readOnlyHint ? "READ" : "WRITE"}</div>
</div>
<div class="actions">
<button class="soft" onclick="prepareToolCall('${escapeJs(tool.name)}')">이 Tool 호출</button>
</div>
</div>`).join("") : `<div class="header-meta" style="text-align:left;color:var(--muted)">선택한 서버의 Tool이 없습니다.</div>`;
}
function mcpTestAction(action) {
const filter = document.getElementById("mcpToolServerFilter").value;
const routeKey = filter === "business." ? "business"
: filter === "cus" ? "cus" : "external";
return `/api/mcp/${routeKey}/${action}`;
}
function prepareToolCall(name) {
setToolPreset(name);
document.getElementById("toolPreset").value = name;
document.getElementById("toolPreset").closest("details").open = true;
document.getElementById("toolPreset").scrollIntoView({behavior: "smooth", block: "center"});
}
function setMessage(value) {
document.getElementById("message").value = value;
}
function setWeather() {
document.getElementById("toolName").value = "external.weather_lookup";
document.getElementById("arguments").value = JSON.stringify({
city: "Seoul",
timezone: "Asia/Seoul"
}, null, 2);
}
function setExchange() {
document.getElementById("toolName").value = "external.exchange_rate";
document.getElementById("arguments").value = JSON.stringify({
from: "USD",
to: "KRW"
}, null, 2);
}
loadConfig().then(loadRegistry).catch(error => {
config.textContent = "Configuration load failed";
output.textContent = JSON.stringify({error: error.message}, null, 2);
});
</script>
</body>
</html>

View File

@@ -1,123 +0,0 @@
package com.example.agenttest;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class McpProxyControllerTest {
@Test
void treatsDirectExternalToolPayloadAsSuccessfulResult() throws Exception {
var payload = new com.fasterxml.jackson.databind.ObjectMapper().readTree(
"{\"year\":2026,\"holidays\":[{\"date\":\"2026-01-01\",\"localName\":\"New Year\"}]}" );
assertThat(McpProxyController.answer("external.public_holiday_lookup", payload))
.contains("2026", "1", "2026-01-01", "New Year")
.doesNotContain("실패");
}
@Test
void treatsDirectOthToolPayloadAsSuccessfulResult() throws Exception {
var payload = new com.fasterxml.jackson.databind.ObjectMapper().readTree(
"{\"codeList\":[{\"code\":\"CD001\",\"codeName\":\"진행중\"}]}");
assertThat(McpProxyController.answer("cmm_comcode_lookup", payload))
.contains("cmm_comcode_lookup 실행 결과")
.contains("CD001")
.doesNotContain("실패");
}
@Autowired
private MockMvc mockMvc;
@Test
void portalRegistryReturnsOkForExternalRoute() throws Exception {
mockMvc.perform(get("/api/portal/registry/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.routeKey").value("external"))
.andExpect(jsonPath("$.toolServices.length()").value(1))
.andExpect(jsonPath("$.toolServices[0].serviceKey").value("external-tools"));
}
@Test
void bumpsPortalRegistryRevision() throws Exception {
mockMvc.perform(post("/api/portal/registry/external/revision"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.routeKey").value("external"))
.andExpect(jsonPath("$.registryRevision").isNumber());
}
@Test
void aggregatePortalRegistryReturnsEveryRouteAndToolService() throws Exception {
mockMvc.perform(get("/api/portal/registry"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.registryRevision").isNumber())
.andExpect(jsonPath("$.routes[?(@.routeKey=='external')].toolServices[0].serviceKey")
.value("external-tools"))
.andExpect(jsonPath("$.routes[?(@.routeKey=='business')].toolServices[0].serviceKey")
.value("business-tools"))
.andExpect(jsonPath("$.routes[?(@.routeKey=='cus')].toolServices[0].serviceKey")
.value("was-cus"));
}
@Test
void exposesBusinessBundleAndEndpointMappingsWithoutApiKey() throws Exception {
mockMvc.perform(get("/api/portal/bundles"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].definition.apiKey").doesNotExist())
.andExpect(jsonPath("$[0].definition.apiKeyConfigured").isBoolean())
.andExpect(jsonPath("$[0].definition.bundleId").value("business-tools"))
.andExpect(jsonPath("$[0].definition.toolEndpoints['business.customer_search']")
.value("/mcp/business.customer_search"));
mockMvc.perform(get("/api/portal/registry/business"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.toolServices[0].serviceKey").value("business-tools"))
.andExpect(jsonPath("$.toolServices[0].toolEndpoints['business.ticket_create']")
.value("/mcp/business.ticket_create"));
}
@Test
void selectsPublicHolidayToolForKoreanHolidayRequest() {
McpProxyController.PlannedTool plan = McpProxyController.planRequest("지금 현재 공휴일 조회해줘");
assertThat(plan.name()).isEqualTo("external.public_holiday_lookup");
assertThat(plan.routeKey()).isEqualTo("external");
assertThat(plan.arguments()).containsEntry("countryCode", "KR").containsKey("year");
}
@Test
void selectsEveryManifestToolFromNaturalLanguageExamples() {
assertThat(McpProxyController.planRequest("서울 날씨 조회").name()).isEqualTo("external.weather_lookup");
assertThat(McpProxyController.planRequest("달러 환율 조회").name()).isEqualTo("external.exchange_rate");
assertThat(McpProxyController.planRequest("서울 좌표 조회").name()).isEqualTo("external.geocoding_lookup");
assertThat(McpProxyController.planRequest("한국 국가 정보 조회").name()).isEqualTo("external.country_info_lookup");
assertThat(McpProxyController.planRequest("고객 검색").name()).isEqualTo("business.customer_search");
assertThat(McpProxyController.planRequest("주문 상태 조회").name()).isEqualTo("business.order_status");
assertThat(McpProxyController.planRequest("고객 지원 티켓 생성").name()).isEqualTo("business.ticket_create");
assertThat(McpProxyController.planRequest("메타 공통코드 조회").name()).isEqualTo("cmm_comcode_lookup");
assertThat(McpProxyController.planRequest("메타 테이블 조회").name()).isEqualTo("cmm_meta_table");
assertThat(McpProxyController.planRequest("템플릿 다운로드 URL 알려줘").name()).isEqualTo("cmm_template_url");
assertThat(McpProxyController.planRequest("SOL 의뢰서 목록 조회").name()).isEqualTo("sol_request_list");
assertThat(McpProxyController.planRequest("SOL 의뢰서 상세 조회").name()).isEqualTo("sol_request_detail");
assertThat(McpProxyController.planRequest("보험금 청구 처리").name()).isEqualTo("ins_insurance_processor");
assertThat(McpProxyController.planRequest("가입설계 한도 조회").name()).isEqualTo("oth_onnba3011_call");
assertThat(McpProxyController.planRequest("CUS 달러 환율 조회").name()).isEqualTo("smp_exchange_inquiry");
assertThat(McpProxyController.planRequest("CUS 서울 날씨 조회").name()).isEqualTo("smp_weather_inquiry");
assertThat(McpProxyController.planRequest("오늘의 명언 알려줘").name()).isEqualTo("smp_quote_daily");
assertThat(McpProxyController.planRequest("TOOL 파트 구성원 조회").name()).isEqualTo("smp_team_list");
assertThat(McpProxyController.planRequest("고객 검색").routeKey()).isEqualTo("business");
assertThat(McpProxyController.planRequest("서울 날씨 조회").routeKey()).isEqualTo("external");
assertThat(McpProxyController.planRequest("메타 테이블 조회").routeKey()).isEqualTo("cus");
}
}

View File

@@ -1,24 +0,0 @@
package com.example.agenttest;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
class PortalApiExceptionHandlerTest {
@Test
void exposesSafeValidationReasonToPortalUi() {
var response = new PortalApiExceptionHandler().handle(
new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Manifest Bundle ID does not match configuration"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).containsAllEntriesOf(Map.of(
"status", 400,
"error", "400 BAD_REQUEST",
"message", "Manifest Bundle ID does not match configuration"));
}
}

View File

@@ -1,83 +0,0 @@
package com.example.agenttest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.example.agenttest.PortalBundleService.BundleRequest;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
class PortalBundleServiceTest {
@Test
void exposesSeededWasCusBundleOnCusRoute() {
PortalBundleService service = new PortalBundleService(properties(), RestClient.builder());
var bundle = service.list().stream()
.filter(item -> item.definition().bundleId().equals("was-cus"))
.findFirst().orElseThrow();
assertThat(bundle.definition().manifestUrl()).isEqualTo("http://localhost:8084/tool-manifest");
assertThat(bundle.definition().namePrefix()).isEmpty();
assertThat(bundle.definition().manifestPollIntervalSeconds()).isEqualTo(10);
assertThat(bundle.definition().toolEndpoints())
.containsEntry("smp_weather_inquiry", "/mcp/smp_weather_inquiry")
.containsEntry("ins_insurance_processor", "/mcp/ins_insurance_processor")
.containsEntry("cmm_customer_tool", "/mcp/cmm_customer_tool")
.hasSize(12);
assertThat(service.portalRegistry("cus").get("toolServices").toString()).contains("was-cus");
assertThat(service.portalRegistry("external").get("toolServices").toString()).doesNotContain("was-cus");
}
@Test
void exposesOnlyEndpointRegistryWithoutManifestSnapshot() {
PortalBundleService service = new PortalBundleService(properties(), RestClient.builder());
var business = service.list().stream()
.filter(item -> item.definition().bundleId().equals("business-tools"))
.findFirst().orElseThrow();
Map<String, Object> registry = service.portalRegistry("business");
assertThat(business.manifestRevision()).isNull();
assertThat(business.lastSynchronizedAt()).isNull();
assertThat(business.lastError()).isNull();
assertThat(business.tools()).isEmpty();
assertThat(registry.get("toolServices").toString())
.contains("serviceDomain=http://localhost:9090")
.contains("manifestPath=/tool-manifest")
.contains("business.customer_search=/mcp/business.customer_search");
}
@Test
void rejectsDuplicateBundleIdManifestUrlAndInvalidUrl() {
PortalBundleService service = new PortalBundleService(properties(), RestClient.builder());
BundleRequest duplicateId = request("business-tools", "http://localhost:9191/tool-manifest");
BundleRequest duplicateUrl = request("another-tools", "http://localhost:9090/tool-manifest");
BundleRequest invalidUrl = request("invalid-tools", "not-a-url");
assertThatThrownBy(() -> service.create(duplicateId))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409 CONFLICT");
assertThatThrownBy(() -> service.create(duplicateUrl))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409 CONFLICT");
assertThatThrownBy(() -> service.create(invalidUrl))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
private BundleRequest request(String bundleId, String manifestUrl) {
return new BundleRequest(bundleId, "Test", manifestUrl, "http://localhost:9191",
"test.", true, 60, "", Map.of());
}
private AgentTestProperties properties() {
return new AgentTestProperties(
new AgentTestProperties.Mcp("http://localhost:8080/mcp", "2025-11-25"),
new AgentTestProperties.ToolServer("http://localhost:9092/tool-manifest", "secret-key"),
new AgentTestProperties.Portal(1, "external", "http://localhost:9092"));
}
}