feat: implement dynamic mcp server routing to bypass SSE deprecation and 50 tools limit

This commit is contained in:
jade
2026-07-15 22:39:21 +09:00
parent 5f908c3e53
commit 2846dacab0
3 changed files with 195 additions and 22 deletions

View File

@@ -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
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 김형식 최초생성
*
* </pre>
*/
@Configuration
public class DynamicMcpRouterConfig {
@Bean
public RouterFunction<ServerResponse> dynamicMcpRouterFunction(DynamicMcpServerManager manager) {
return manager.getDynamicRouter();
}
}

View File

@@ -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
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 김형식 최초생성
*
* </pre>
*/
@Component
public class DynamicMcpServerManager {
private static final Logger log = LoggerFactory.getLogger(DynamicMcpServerManager.class);
private final Map<String, McpSyncServer> categoryServers = new ConcurrentHashMap<>();
private final Map<String, WebMvcSseServerTransportProvider> categoryTransports = new ConcurrentHashMap<>();
private final Map<String, Set<String>> managedToolNamesPerCategory = new ConcurrentHashMap<>();
private final RegistryMcpToolSpecificationFactory specificationFactory;
public DynamicMcpServerManager(RegistryMcpToolSpecificationFactory specificationFactory) {
this.specificationFactory = specificationFactory;
}
public void synchronizeCategory(String categoryKey, List<ToolMetadata> 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<String> managedToolNames = managedToolNamesPerCategory.get(safeCategory);
Set<String> activeNames = tools.stream()
.map(ToolMetadata::getName)
.collect(Collectors.toSet());
Set<String> 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<String> getKnownCategories() {
return Collections.unmodifiableSet(categoryServers.keySet());
}
public RouterFunction<ServerResponse> getDynamicRouter() {
return request -> {
for (WebMvcSseServerTransportProvider transport : categoryTransports.values()) {
Optional<HandlerFunction<ServerResponse>> handler = transport.getRouterFunction().route(request);
if (handler.isPresent()) {
return handler;
}
}
return Optional.empty();
};
}
}

View File

@@ -13,27 +13,42 @@ import org.springframework.stereotype.Service;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors; 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
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 김형식 최초생성
*
* </pre>
*/ */
@Service @Service
public class RegistryMcpToolSynchronizer { public class RegistryMcpToolSynchronizer {
private static final Logger log = LoggerFactory.getLogger(RegistryMcpToolSynchronizer.class); private static final Logger log = LoggerFactory.getLogger(RegistryMcpToolSynchronizer.class);
private final ObjectProvider<McpSyncServer> mcpServerProvider; private final ObjectProvider<McpSyncServer> mcpServerProvider;
private final DynamicMcpServerManager dynamicMcpServerManager;
private final RedisRegistryService redisRegistryService; private final RedisRegistryService redisRegistryService;
private final RegistryMcpToolSpecificationFactory specificationFactory; private final RegistryMcpToolSpecificationFactory specificationFactory;
private final Set<String> managedToolNames = new LinkedHashSet<>(); private final Set<String> managedToolNames = new LinkedHashSet<>();
private final ReentrantLock lock = new ReentrantLock(); private final ReentrantLock lock = new ReentrantLock();
public RegistryMcpToolSynchronizer(ObjectProvider<McpSyncServer> mcpServerProvider, public RegistryMcpToolSynchronizer(ObjectProvider<McpSyncServer> mcpServerProvider,
DynamicMcpServerManager dynamicMcpServerManager,
RedisRegistryService redisRegistryService, RedisRegistryService redisRegistryService,
RegistryMcpToolSpecificationFactory specificationFactory) { RegistryMcpToolSpecificationFactory specificationFactory) {
this.mcpServerProvider = mcpServerProvider; this.mcpServerProvider = mcpServerProvider;
this.dynamicMcpServerManager = dynamicMcpServerManager;
this.redisRegistryService = redisRegistryService; this.redisRegistryService = redisRegistryService;
this.specificationFactory = specificationFactory; this.specificationFactory = specificationFactory;
} }
@@ -49,18 +64,29 @@ public class RegistryMcpToolSynchronizer {
} }
public void synchronize() { public void synchronize() {
McpSyncServer server = mcpServerProvider.getIfAvailable();
if (server == null) {
return;
}
lock.lock(); lock.lock();
try { try {
List<ToolMetadata> activeEntries = redisRegistryService.getAllTools() List<ToolMetadata> activeEntries = redisRegistryService.getAllTools()
.stream().filter(ToolMetadata::getVisible).collect(Collectors.toList()); .stream().filter(ToolMetadata::getVisible).collect(Collectors.toList());
// 1. Dynamic MCP Server 동기화 (카테고리별)
Map<String, List<ToolMetadata>> toolsByCategory = activeEntries.stream()
.collect(Collectors.groupingBy(
t -> (t.getCategoryKey() == null || t.getCategoryKey().trim().isEmpty()) ? "common" : t.getCategoryKey()
));
Set<String> allKnownCategories = new LinkedHashSet<>(dynamicMcpServerManager.getKnownCategories());
allKnownCategories.addAll(toolsByCategory.keySet());
for (String category : allKnownCategories) {
dynamicMcpServerManager.synchronizeCategory(category, toolsByCategory.getOrDefault(category, List.of()));
}
// 2. Global MCP Server 동기화 (기존 하위 호환)
McpSyncServer server = mcpServerProvider.getIfAvailable();
if (server != null) {
Set<String> activeNames = activeEntries.stream() Set<String> activeNames = activeEntries.stream()
.map(ToolMetadata::getName) // We use 'name' for MCP Tool Name .map(ToolMetadata::getName)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
Set<String> removedNames = new LinkedHashSet<>(managedToolNames); Set<String> removedNames = new LinkedHashSet<>(managedToolNames);
@@ -68,7 +94,7 @@ public class RegistryMcpToolSynchronizer {
for (String removedName : removedNames) { for (String removedName : removedNames) {
server.removeTool(removedName); server.removeTool(removedName);
managedToolNames.remove(removedName); managedToolNames.remove(removedName);
log.info("Removed MCP registry tool. tool={}", removedName); log.info("Removed MCP registry tool (Global). tool={}", removedName);
} }
for (ToolMetadata entry : activeEntries) { for (ToolMetadata entry : activeEntries) {
@@ -78,6 +104,7 @@ public class RegistryMcpToolSynchronizer {
server.addTool(specificationFactory.create(entry)); server.addTool(specificationFactory.create(entry));
managedToolNames.add(entry.getName()); managedToolNames.add(entry.getName());
} }
}
} catch (Exception error) { } catch (Exception error) {
log.warn("MCP registry tool synchronization failed. message={}", error.getMessage()); log.warn("MCP registry tool synchronization failed. message={}", error.getMessage());
} finally { } finally {