Update project functionality and configuration
This commit is contained in:
@@ -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