Update project functionality and configuration
This commit is contained in:
18
potal/agent-test-backend/.classpath
Normal file
18
potal/agent-test-backend/.classpath
Normal file
@@ -0,0 +1,18 @@
|
||||
<?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>
|
||||
28
potal/agent-test-backend/.project
Normal file
28
potal/agent-test-backend/.project
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -0,0 +1,4 @@
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
boot.validation.initialized=true
|
||||
eclipse.preferences.version=1
|
||||
137
potal/agent-test-backend/README.md
Normal file
137
potal/agent-test-backend/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 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`.
|
||||
18
potal/agent-test-backend/bin/main/application.yml
Normal file
18
potal/agent-test-backend/bin/main/application.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
625
potal/agent-test-backend/bin/main/static/index.html
Normal file
625
potal/agent-test-backend/bin/main/static/index.html
Normal file
@@ -0,0 +1,625 @@
|
||||
<!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 => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[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>
|
||||
27
potal/agent-test-backend/build.gradle
Normal file
27
potal/agent-test-backend/build.gradle
Normal file
@@ -0,0 +1,27 @@
|
||||
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()
|
||||
}
|
||||
BIN
potal/agent-test-backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
potal/agent-test-backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
potal/agent-test-backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
potal/agent-test-backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
251
potal/agent-test-backend/gradlew
vendored
Normal file
251
potal/agent-test-backend/gradlew
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/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" "$@"
|
||||
94
potal/agent-test-backend/gradlew.bat
vendored
Normal file
94
potal/agent-test-backend/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@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
|
||||
15
potal/agent-test-backend/settings.gradle
Normal file
15
potal/agent-test-backend/settings.gradle
Normal file
@@ -0,0 +1,15 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'agent-test-backend'
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
18
potal/agent-test-backend/src/main/resources/application.yml
Normal file
18
potal/agent-test-backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
625
potal/agent-test-backend/src/main/resources/static/index.html
Normal file
625
potal/agent-test-backend/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,625 @@
|
||||
<!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 => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[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>
|
||||
@@ -0,0 +1,123 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user