Update project functionality and configuration

This commit is contained in:
2026-08-14 17:59:07 +09:00
parent a4eb5a580f
commit 189277a78c
113 changed files with 4838 additions and 348 deletions

View File

@@ -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);
}
}

View File

@@ -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) {
}
}

View File

@@ -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) {
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View 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

View 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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[char]);
}
function escapeJs(value) {
return String(value).replace(/[\\']/g, "\\$&");
}
async function bumpRevision() {
const data = await callApi("POST", `/api/portal/registry/${selectedRoute()}/revision`);
await loadRegistry();
output.textContent = JSON.stringify({
message: "Portal registry revision bumped",
routeKey: data.routeKey,
registryRevision: data.registryRevision
}, null, 2);
}
function renderGroup(title, rows) {
return `<div><h3>${title}</h3>${rows.map(renderItem).join("")}</div>`;
}
function renderItem(row) {
const title = row.displayName || row.serviceKey || row.routeKey;
const entries = Object.entries(row)
.map(([key, value]) => `<div>${key}</div><div>${value}</div>`)
.join("");
return `<div class="item"><div class="item-title">${title} <span class="badge">${row.status || row.source}</span></div><div class="kv">${entries}</div></div>`;
}
async function sendChat() {
const message = document.getElementById("message").value.trim();
answer.textContent = "Agent is selecting a tool...";
const data = await callApi("POST", "/api/agent/chat", {message});
answer.innerHTML = `<strong>${data.answer}</strong><br><br>Agent route 결정: ${data.routeKey}<br>판단: ${data.routeDecision}<br>MCP: ${data.mcpEndpointUrl}<br>Tool: ${data.selectedTool}<br>인자: ${JSON.stringify(data.arguments)}`;
}
async function callTool() {
const name = document.getElementById("toolName").value.trim();
const args = JSON.parse(document.getElementById("arguments").value);
const routeKey = name.startsWith("business.") ? "business"
: /^(cmm|ins|oth|smp|sol)_/.test(name) ? "cus" : "external";
await callApi("POST", `/api/mcp/${routeKey}/tools/call`, {name, arguments: args});
}
function setToolPreset(name) {
const presets = {
"external.weather_lookup": {city: "Seoul", timezone: "Asia/Seoul"},
"external.exchange_rate": {from: "USD", to: "KRW"},
"external.public_holiday_lookup": {countryCode: "KR", year: new Date().getFullYear()},
"external.geocoding_lookup": {city: "Seoul", language: "ko"},
"external.country_info_lookup": {countryCode: "KR"},
"business.customer_search": {keyword: "C-1001"},
"business.order_status": {orderId: "O-9001"},
"business.ticket_create": {
title: "Portal test ticket",
priority: "normal",
description: "Created from the Portal Tool test screen"
},
"cmm_comcode_lookup": {groupCode: "GRP_COMM_CD", useYn: "Y"},
"cmm_customer_tool": {csNo: "000000000001"},
"cmm_meta_table": {tableName: "TB_CUST_BAS", owner: "DAPADM"},
"cmm_template_url": {templateId: "TPL_001"},
"sol_request_list": {status: "진행중", period: "1개월", target: "나의 업무"},
"sol_request_detail": {srId: "SR-001"},
"ins_insurance_processor": {claimNumber: "CLM20230001", claimAmount: 1500000, claimDate: "2026-08-12"},
"oth_onnba3011_call": {dalScCd: "1", cstSucoRltyCd: "01", csNo: "000000000001"},
"smp_exchange_inquiry": {currencyCode: "USD"},
"smp_weather_inquiry": {city: "서울"},
"smp_quote_daily": {category: "속담"},
"smp_team_list": {teamName: "TOOL"}
};
document.getElementById("toolName").value = name;
document.getElementById("arguments").value = JSON.stringify(presets[name] || {}, null, 2);
}
function selectedRoute() {
const route = document.getElementById("routeKey").value.trim();
return route || "cus";
}
function mcpAction(action) {
return `/api/mcp/${selectedRoute()}/${action}`;
}
async function loadMcpTools() {
const data = await callApi("POST", mcpTestAction("tools/list"));
const prefix = document.getElementById("mcpToolServerFilter").value;
const tools = (((data || {}).body || {}).result || {}).tools || [];
const filtered = prefix === "all" || prefix === "cus"
? tools : tools.filter(tool => tool.name.startsWith(prefix));
const target = document.getElementById("mcpToolList");
target.innerHTML = filtered.length ? filtered.map(tool => `
<div class="item">
<div class="item-title">${escapeHtml(tool.title || tool.name)}</div>
<div class="kv">
<div>name</div><div>${escapeHtml(tool.name)}</div>
<div>설명</div><div>${escapeHtml(tool.description || "-")}</div>
<div>유형</div><div>${tool.annotations && tool.annotations.readOnlyHint ? "READ" : "WRITE"}</div>
</div>
<div class="actions">
<button class="soft" onclick="prepareToolCall('${escapeJs(tool.name)}')">이 Tool 호출</button>
</div>
</div>`).join("") : `<div class="header-meta" style="text-align:left;color:var(--muted)">선택한 서버의 Tool이 없습니다.</div>`;
}
function mcpTestAction(action) {
const filter = document.getElementById("mcpToolServerFilter").value;
const routeKey = filter === "business." ? "business"
: filter === "cus" ? "cus" : "external";
return `/api/mcp/${routeKey}/${action}`;
}
function prepareToolCall(name) {
setToolPreset(name);
document.getElementById("toolPreset").value = name;
document.getElementById("toolPreset").closest("details").open = true;
document.getElementById("toolPreset").scrollIntoView({behavior: "smooth", block: "center"});
}
function setMessage(value) {
document.getElementById("message").value = value;
}
function setWeather() {
document.getElementById("toolName").value = "external.weather_lookup";
document.getElementById("arguments").value = JSON.stringify({
city: "Seoul",
timezone: "Asia/Seoul"
}, null, 2);
}
function setExchange() {
document.getElementById("toolName").value = "external.exchange_rate";
document.getElementById("arguments").value = JSON.stringify({
from: "USD",
to: "KRW"
}, null, 2);
}
loadConfig().then(loadRegistry).catch(error => {
config.textContent = "Configuration load failed";
output.textContent = JSON.stringify({error: error.message}, null, 2);
});
</script>
</body>
</html>

View File

@@ -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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}