diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java
new file mode 100644
index 0000000..5712118
--- /dev/null
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/mcp/McpClientDemoResult.java
@@ -0,0 +1,12 @@
+package com.cloudhandson.vpdbackoffice.domain.mcp;
+
+public record McpClientDemoResult(
+ String contextPath,
+ String messageUrl,
+ String selectedTool,
+ String initializeResponse,
+ String toolsListResponse,
+ String toolsCallResponse,
+ String status
+) {
+}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java
new file mode 100644
index 0000000..63e60c4
--- /dev/null
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/McpClientDemoService.java
@@ -0,0 +1,144 @@
+package com.cloudhandson.vpdbackoffice.service;
+
+import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
+import com.cloudhandson.vpdbackoffice.domain.mcp.McpClientDemoResult;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.net.URI;
+import java.time.Duration;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+@Service
+public class McpClientDemoService {
+
+ private final BackofficeProperties properties;
+ private final ObjectMapper objectMapper;
+ private final RestTemplate restTemplate;
+
+ public McpClientDemoService(
+ BackofficeProperties properties,
+ ObjectMapper objectMapper,
+ RestTemplateBuilder restTemplateBuilder
+ ) {
+ this.properties = properties;
+ this.objectMapper = objectMapper;
+ this.restTemplate = restTemplateBuilder
+ .setConnectTimeout(Duration.ofSeconds(5))
+ .setReadTimeout(Duration.ofSeconds(15))
+ .build();
+ }
+
+ public McpClientDemoResult run(
+ String serverOrigin,
+ String contextPath,
+ String toolName,
+ String bearerToken,
+ int limit
+ ) {
+ String normalizedContextPath = normalizeContextPath(contextPath);
+ URI messageUri = messageUri(serverOrigin, normalizedContextPath);
+
+ JsonNode initializeResponse = post(messageUri, initializeRequest(1));
+ JsonNode toolsListResponse = post(messageUri, toolsListRequest(2));
+ JsonNode toolsCallResponse = null;
+ if (hasText(toolName) && hasText(bearerToken)) {
+ toolsCallResponse = post(messageUri, toolsCallRequest(3, toolName.trim(), bearerToken.trim(), normalizeLimit(limit)));
+ }
+
+ return new McpClientDemoResult(
+ normalizedContextPath,
+ messageUri.toString(),
+ hasText(toolName) ? toolName.trim() : "",
+ pretty(initializeResponse),
+ pretty(toolsListResponse),
+ toolsCallResponse == null ? "Bearer Token이 없어 tools/call은 실행하지 않았습니다." : pretty(toolsCallResponse),
+ toolsCallResponse == null ? "TOOLS_LIST_ONLY" : "SUCCESS"
+ );
+ }
+
+ private JsonNode post(URI uri, ObjectNode request) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.setBasicAuth(properties.security().adminUser(), properties.security().adminPassword());
+ String response = restTemplate.postForObject(uri, new HttpEntity<>(request, headers), String.class);
+ try {
+ return objectMapper.readTree(response);
+ } catch (Exception e) {
+ throw new AppException("MCP client 응답 JSON 파싱 실패: " + e.getMessage());
+ }
+ }
+
+ private URI messageUri(String serverOrigin, String contextPath) {
+ String path = "default".equals(contextPath) ? "/mcp/messages" : "/mcp/" + contextPath + "/messages";
+ return UriComponentsBuilder.fromUriString(serverOrigin)
+ .path(path)
+ .build()
+ .toUri();
+ }
+
+ private ObjectNode initializeRequest(int id) {
+ ObjectNode request = request(id, "initialize");
+ request.set("params", objectMapper.createObjectNode());
+ return request;
+ }
+
+ private ObjectNode toolsListRequest(int id) {
+ ObjectNode request = request(id, "tools/list");
+ request.set("params", objectMapper.createObjectNode());
+ return request;
+ }
+
+ private ObjectNode toolsCallRequest(int id, String toolName, String bearerToken, int limit) {
+ ObjectNode request = request(id, "tools/call");
+ ObjectNode params = objectMapper.createObjectNode();
+ params.put("name", toolName);
+ ObjectNode arguments = objectMapper.createObjectNode();
+ arguments.put("bearerToken", bearerToken);
+ arguments.put("limit", limit);
+ params.set("arguments", arguments);
+ request.set("params", params);
+ return request;
+ }
+
+ private ObjectNode request(int id, String method) {
+ ObjectNode request = objectMapper.createObjectNode();
+ request.put("jsonrpc", "2.0");
+ request.put("id", id);
+ request.put("method", method);
+ return request;
+ }
+
+ private String normalizeContextPath(String contextPath) {
+ String normalized = contextPath == null || contextPath.isBlank() ? "default" : contextPath.trim();
+ if (!normalized.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) {
+ throw new AppException("MCP context path는 영문/숫자로 시작하고 영문/숫자/_/-만 사용할 수 있습니다: " + contextPath);
+ }
+ return normalized.toLowerCase();
+ }
+
+ private int normalizeLimit(int limit) {
+ if (limit < 1) {
+ return 50;
+ }
+ return Math.min(limit, 500);
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+
+ private String pretty(JsonNode node) {
+ try {
+ return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(node);
+ } catch (Exception e) {
+ return String.valueOf(node);
+ }
+ }
+}
diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java
new file mode 100644
index 0000000..1f92265
--- /dev/null
+++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/McpClientDemoController.java
@@ -0,0 +1,64 @@
+package com.cloudhandson.vpdbackoffice.web;
+
+import com.cloudhandson.vpdbackoffice.service.McpClientDemoService;
+import com.cloudhandson.vpdbackoffice.service.McpToolRegistry;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.List;
+import org.springframework.dao.DataAccessException;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+@Controller
+public class McpClientDemoController {
+
+ private final McpToolRegistry toolRegistry;
+ private final McpClientDemoService demoService;
+
+ public McpClientDemoController(McpToolRegistry toolRegistry, McpClientDemoService demoService) {
+ this.toolRegistry = toolRegistry;
+ this.demoService = demoService;
+ }
+
+ @GetMapping("/mcp-client-demo")
+ public String page(Model model) {
+ addTools(model);
+ return "mcp-client-demo";
+ }
+
+ @PostMapping("/mcp-client-demo")
+ public String run(
+ @RequestParam(defaultValue = "vpd-live") String contextPath,
+ @RequestParam(defaultValue = "") String toolName,
+ @RequestParam(defaultValue = "") String bearerToken,
+ @RequestParam(defaultValue = "50") int limit,
+ HttpServletRequest request,
+ Model model
+ ) {
+ addTools(model);
+ try {
+ String serverOrigin = ServletUriComponentsBuilder.fromRequestUri(request)
+ .replacePath(null)
+ .replaceQuery(null)
+ .build()
+ .toUriString();
+ model.addAttribute("result", demoService.run(serverOrigin, contextPath, toolName, bearerToken, limit));
+ } catch (Exception e) {
+ model.addAttribute("errorMessage", e.getMessage());
+ }
+ return "fragments/mcp-client-demo-result :: result";
+ }
+
+ private void addTools(Model model) {
+ try {
+ model.addAttribute("tools", toolRegistry.listTools());
+ } catch (DataAccessException e) {
+ RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(e);
+ model.addAttribute("tools", List.of());
+ model.addAttribute("runtimeError", message);
+ }
+ }
+}
diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html
index 0655371..ad2c139 100644
--- a/src/main/resources/templates/fragments/layout.html
+++ b/src/main/resources/templates/fragments/layout.html
@@ -37,6 +37,7 @@