feat: implement dynamic mcp server routing to bypass SSE deprecation and 50 tools limit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* <pre>
|
||||
* ---------- 개정이력 ----------
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- -------- ---------------------------
|
||||
* 2026.09.01 김형식 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
@Service
|
||||
public class RegistryMcpToolSynchronizer {
|
||||
private static final Logger log = LoggerFactory.getLogger(RegistryMcpToolSynchronizer.class);
|
||||
|
||||
private final ObjectProvider<McpSyncServer> mcpServerProvider;
|
||||
private final DynamicMcpServerManager dynamicMcpServerManager;
|
||||
private final RedisRegistryService redisRegistryService;
|
||||
private final RegistryMcpToolSpecificationFactory specificationFactory;
|
||||
private final Set<String> managedToolNames = new LinkedHashSet<>();
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
|
||||
public RegistryMcpToolSynchronizer(ObjectProvider<McpSyncServer> mcpServerProvider,
|
||||
DynamicMcpServerManager dynamicMcpServerManager,
|
||||
RedisRegistryService redisRegistryService,
|
||||
RegistryMcpToolSpecificationFactory specificationFactory) {
|
||||
this.mcpServerProvider = mcpServerProvider;
|
||||
this.dynamicMcpServerManager = dynamicMcpServerManager;
|
||||
this.redisRegistryService = redisRegistryService;
|
||||
this.specificationFactory = specificationFactory;
|
||||
}
|
||||
@@ -49,18 +64,29 @@ public class RegistryMcpToolSynchronizer {
|
||||
}
|
||||
|
||||
public void synchronize() {
|
||||
McpSyncServer server = mcpServerProvider.getIfAvailable();
|
||||
if (server == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
List<ToolMetadata> activeEntries = redisRegistryService.getAllTools()
|
||||
.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()
|
||||
.map(ToolMetadata::getName) // We use 'name' for MCP Tool Name
|
||||
.map(ToolMetadata::getName)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
|
||||
Set<String> removedNames = new LinkedHashSet<>(managedToolNames);
|
||||
@@ -68,7 +94,7 @@ public class RegistryMcpToolSynchronizer {
|
||||
for (String removedName : removedNames) {
|
||||
server.removeTool(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) {
|
||||
@@ -78,6 +104,7 @@ public class RegistryMcpToolSynchronizer {
|
||||
server.addTool(specificationFactory.create(entry));
|
||||
managedToolNames.add(entry.getName());
|
||||
}
|
||||
}
|
||||
} catch (Exception error) {
|
||||
log.warn("MCP registry tool synchronization failed. message={}", error.getMessage());
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user