From 2846dacab0ad6fd792745b2d788858c2a63cf6c1 Mon Sep 17 00:00:00 2001 From: jade Date: Wed, 15 Jul 2026 22:39:21 +0900 Subject: [PATCH] feat: implement dynamic mcp server routing to bypass SSE deprecation and 50 tools limit --- .../gateway/sync/DynamicMcpRouterConfig.java | 29 +++++ .../gateway/sync/DynamicMcpServerManager.java | 117 ++++++++++++++++++ .../sync/RegistryMcpToolSynchronizer.java | 71 +++++++---- 3 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpRouterConfig.java create mode 100644 axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpServerManager.java diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpRouterConfig.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpRouterConfig.java new file mode 100644 index 0000000..78bf870 --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpRouterConfig.java @@ -0,0 +1,29 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.sync; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +/** + * @package io.shinhanlife.axhub.biz.mcp.gateway.sync + * @className DynamicMcpRouterConfig + * @description AX HUB 시스템 처리 클래스 + * @author 김형식 + * @create 2026.09.01 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.09.01  김형식    최초생성
+ * 
+ * 
+ */ +@Configuration +public class DynamicMcpRouterConfig { + + @Bean + public RouterFunction dynamicMcpRouterFunction(DynamicMcpServerManager manager) { + return manager.getDynamicRouter(); + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpServerManager.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpServerManager.java new file mode 100644 index 0000000..ed1779d --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/DynamicMcpServerManager.java @@ -0,0 +1,117 @@ +package io.shinhanlife.axhub.biz.mcp.gateway.sync; + +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.server.webmvc.transport.WebMvcSseServerTransportProvider; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.function.HandlerFunction; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * @package io.shinhanlife.axhub.biz.mcp.gateway.sync + * @className DynamicMcpServerManager + * @description AX HUB 시스템 처리 클래스 + * @author 김형식 + * @create 2026.09.01 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.09.01  김형식    최초생성
+ * 
+ * 
+ */ +@Component +public class DynamicMcpServerManager { + private static final Logger log = LoggerFactory.getLogger(DynamicMcpServerManager.class); + + private final Map categoryServers = new ConcurrentHashMap<>(); + private final Map categoryTransports = new ConcurrentHashMap<>(); + private final Map> managedToolNamesPerCategory = new ConcurrentHashMap<>(); + + private final RegistryMcpToolSpecificationFactory specificationFactory; + + public DynamicMcpServerManager(RegistryMcpToolSpecificationFactory specificationFactory) { + this.specificationFactory = specificationFactory; + } + + public void synchronizeCategory(String categoryKey, List tools) { + if (categoryKey == null || categoryKey.trim().isEmpty()) { + categoryKey = "common"; + } + String safeCategory = categoryKey.toLowerCase(); + + McpSyncServer server = categoryServers.computeIfAbsent(safeCategory, key -> { + log.info("Creating dynamic MCP Server for category: {}", key); + String ssePath = "common".equals(key) ? "/mcp/sse" : "/mcp/sse/" + key; + String msgPath = "common".equals(key) ? "/mcp/message" : "/mcp/message/" + key; + + WebMvcSseServerTransportProvider transport = WebMvcSseServerTransportProvider.builder() + .sseEndpoint(ssePath) + .messageEndpoint(msgPath) + .build(); + + McpSyncServer newServer = McpServer.sync(transport) + .serverInfo("AXHUB-Gateway-" + key, "1.0.0") + .build(); + + categoryTransports.put(key, transport); + managedToolNamesPerCategory.put(key, ConcurrentHashMap.newKeySet()); + return newServer; + }); + + Set managedToolNames = managedToolNamesPerCategory.get(safeCategory); + Set activeNames = tools.stream() + .map(ToolMetadata::getName) + .collect(Collectors.toSet()); + + Set removedNames = managedToolNames.stream() + .filter(name -> !activeNames.contains(name)) + .collect(Collectors.toSet()); + + for (String removedName : removedNames) { + server.removeTool(removedName); + managedToolNames.remove(removedName); + log.info("[{}] Removed tool: {}", safeCategory, removedName); + } + + for (ToolMetadata entry : tools) { + if (!managedToolNames.contains(entry.getName())) { + server.addTool(specificationFactory.create(entry)); + managedToolNames.add(entry.getName()); + log.info("[{}] Added tool: {}", safeCategory, entry.getName()); + } else { + server.removeTool(entry.getName()); + server.addTool(specificationFactory.create(entry)); + } + } + } + + public Set getKnownCategories() { + return Collections.unmodifiableSet(categoryServers.keySet()); + } + + public RouterFunction getDynamicRouter() { + return request -> { + for (WebMvcSseServerTransportProvider transport : categoryTransports.values()) { + Optional> handler = transport.getRouterFunction().route(request); + if (handler.isPresent()) { + return handler; + } + } + return Optional.empty(); + }; + } +} diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSynchronizer.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSynchronizer.java index a551c58..0ac8c46 100644 --- a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSynchronizer.java +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/sync/RegistryMcpToolSynchronizer.java @@ -13,27 +13,42 @@ import org.springframework.stereotype.Service; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; /** - * MCP 서버의 tools/list 노출 목록을 Redis Registry의 ACTIVE Tool 목록과 동기화합니다. + * @package io.shinhanlife.axhub.biz.mcp.gateway.sync + * @className RegistryMcpToolSynchronizer + * @description AX HUB 시스템 처리 클래스 + * @author 김형식 + * @create 2026.09.01 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.09.01  김형식    최초생성
+ * 
+ * 
*/ @Service public class RegistryMcpToolSynchronizer { private static final Logger log = LoggerFactory.getLogger(RegistryMcpToolSynchronizer.class); private final ObjectProvider mcpServerProvider; + private final DynamicMcpServerManager dynamicMcpServerManager; private final RedisRegistryService redisRegistryService; private final RegistryMcpToolSpecificationFactory specificationFactory; private final Set managedToolNames = new LinkedHashSet<>(); private final ReentrantLock lock = new ReentrantLock(); public RegistryMcpToolSynchronizer(ObjectProvider mcpServerProvider, + DynamicMcpServerManager dynamicMcpServerManager, RedisRegistryService redisRegistryService, RegistryMcpToolSpecificationFactory specificationFactory) { this.mcpServerProvider = mcpServerProvider; + this.dynamicMcpServerManager = dynamicMcpServerManager; this.redisRegistryService = redisRegistryService; this.specificationFactory = specificationFactory; } @@ -49,34 +64,46 @@ public class RegistryMcpToolSynchronizer { } public void synchronize() { - McpSyncServer server = mcpServerProvider.getIfAvailable(); - if (server == null) { - return; - } - lock.lock(); try { List activeEntries = redisRegistryService.getAllTools() .stream().filter(ToolMetadata::getVisible).collect(Collectors.toList()); - - Set activeNames = activeEntries.stream() - .map(ToolMetadata::getName) // We use 'name' for MCP Tool Name - .collect(Collectors.toCollection(LinkedHashSet::new)); - - Set removedNames = new LinkedHashSet<>(managedToolNames); - removedNames.removeAll(activeNames); - for (String removedName : removedNames) { - server.removeTool(removedName); - managedToolNames.remove(removedName); - log.info("Removed MCP registry tool. tool={}", removedName); + + // 1. Dynamic MCP Server 동기화 (카테고리별) + Map> toolsByCategory = activeEntries.stream() + .collect(Collectors.groupingBy( + t -> (t.getCategoryKey() == null || t.getCategoryKey().trim().isEmpty()) ? "common" : t.getCategoryKey() + )); + + Set allKnownCategories = new LinkedHashSet<>(dynamicMcpServerManager.getKnownCategories()); + allKnownCategories.addAll(toolsByCategory.keySet()); + + for (String category : allKnownCategories) { + dynamicMcpServerManager.synchronizeCategory(category, toolsByCategory.getOrDefault(category, List.of())); } - for (ToolMetadata entry : activeEntries) { - if (managedToolNames.contains(entry.getName())) { - server.removeTool(entry.getName()); + // 2. Global MCP Server 동기화 (기존 하위 호환) + McpSyncServer server = mcpServerProvider.getIfAvailable(); + if (server != null) { + Set activeNames = activeEntries.stream() + .map(ToolMetadata::getName) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + Set removedNames = new LinkedHashSet<>(managedToolNames); + removedNames.removeAll(activeNames); + for (String removedName : removedNames) { + server.removeTool(removedName); + managedToolNames.remove(removedName); + log.info("Removed MCP registry tool (Global). tool={}", removedName); + } + + for (ToolMetadata entry : activeEntries) { + if (managedToolNames.contains(entry.getName())) { + server.removeTool(entry.getName()); + } + server.addTool(specificationFactory.create(entry)); + managedToolNames.add(entry.getName()); } - server.addTool(specificationFactory.create(entry)); - managedToolNames.add(entry.getName()); } } catch (Exception error) { log.warn("MCP registry tool synchronization failed. message={}", error.getMessage());