refactor: Migrate MCP Gateway to Spring AI MCP Server

This commit is contained in:
jade
2026-07-14 15:39:55 +09:00
parent a944841427
commit 0308926116
10 changed files with 517 additions and 196 deletions

View File

@@ -60,97 +60,7 @@ public class McpRouterController {
this.restClient = RestClient.builder().requestFactory(factory).build();
}
@Operation(summary = "MCP 파이프라인 호출", description = "MCP 표준 파이프라인(ExecuteService)을 통해 레거시 툴을 호출합니다.")
@PostMapping("/tools/call")
public ResponseEntity<Object> callTool(
@RequestHeader(value = "X-Trace-Id", required = false) String traceId,
@RequestHeader(value = "X-Agent-Id", required = false) String agentId,
@RequestHeader(value = "X-User-Prompt", required = false) String userPrompt,
@RequestBody Map<String, Object> payload,
@RequestAttribute(value = "tenantId", required = false) String tenantId) {
Map<String, Object> meta = new HashMap<>();
meta.put("traceId", traceId != null ? traceId : UUID.randomUUID().toString());
meta.put("agentId", agentId != null ? agentId : "UNKNOWN");
meta.put("userPrompt", userPrompt != null ? userPrompt : "");
payload.put("meta", meta);
log.info("[MCP 표준] ExecuteService 파이프라인을 통한 툴 호출 시작 (Tenant: {}, Trace: {})", tenantId, meta.get("traceId"));
try {
log.info("[AI -> MCP Gateway] 동적 툴 실행 요청 수신: {}", objectMapper.writeValueAsString(payload));
} catch (Exception ex) {
log.info("[AI -> MCP Gateway] 동적 툴 실행 요청 수신: {}", payload);
}
try {
Object result = executeService.execute(payload, tenantId);
try {
log.info("[MCP Gateway -> AI] 동적 툴 실행 결과 반환: {}", objectMapper.writeValueAsString(result));
} catch (Exception ex) {
log.info("[MCP Gateway -> AI] 동적 툴 실행 결과 반환: {}", result);
}
return ResponseEntity.ok(result);
} catch (SecurityException se) {
log.warn(" [보안 차단] 권한 오류: {}", se.getMessage());
return ResponseEntity.status(403).body(Map.of("error", se.getMessage()));
} catch (Exception e) {
log.error(" 파이프라인 실행 중 오류: {}", e.getMessage());
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/tools/list")
public ResponseEntity<JsonRpcResponse> listTools(
@RequestParam(value = "categoryKey", required = false) String categoryKey) {
List<ToolMetadata> activeTools = redisRegistryService.getAllTools()
.stream()
.filter(ToolMetadata::getVisible)
.collect(Collectors.toList());
Set<String> knownTools = activeTools.stream()
.map(ToolMetadata::getUid)
.collect(Collectors.toSet());
Set<String> fallbackUrls = new HashSet<>(gatewayFallbackProperties.getRoutes().values());
if (gatewayFallbackProperties.getDefaultUrl() != null) {
fallbackUrls.add(gatewayFallbackProperties.getDefaultUrl());
}
for (String url : fallbackUrls) {
try {
List<ToolMetadata> localTools = restClient.get()
.uri(url + "/mcp/api/v1/tools/local")
.retrieve()
.body(new ParameterizedTypeReference<List<ToolMetadata>>() {});
if (localTools != null) {
for (ToolMetadata t : localTools) {
if (!Boolean.TRUE.equals(t.getIsRegistered()) && Boolean.TRUE.equals(t.getVisible()) && !knownTools.contains(t.getUid())) {
activeTools.add(t);
knownTools.add(t.getUid());
}
}
}
} catch (Exception e) {
log.warn("Failed to fetch local tools from fallback URL: {}", url);
}
}
if (categoryKey != null && !categoryKey.trim().isEmpty()) {
activeTools = activeTools.stream()
.filter(t -> categoryKey.equals(t.getCategoryKey()))
.collect(Collectors.toList());
}
JsonRpcResponse response = new JsonRpcResponse();
response.setId(UUID.randomUUID().toString());
response.setResult(Map.of("tools", activeTools));
return ResponseEntity.ok(response);
}
@GetMapping(value = "/tools/docs/markdown", produces = "text/markdown;charset=UTF-8")
public ResponseEntity<String> generateToolsMarkdown() {

View File

@@ -0,0 +1,124 @@
package io.shinhanlife.axhub.biz.mcp.gateway.sync;
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
import io.shinhanlife.axhub.biz.mcp.gateway.service.ExecuteService;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Registry에 등록된 실제 Tool 메타데이터를 MCP 표준 Tool specification으로 변환합니다.
*/
@Component
public class RegistryMcpToolSpecificationFactory {
private final ExecuteService executeService;
private final ObjectMapper objectMapper;
public RegistryMcpToolSpecificationFactory(ExecuteService executeService, ObjectMapper objectMapper) {
this.executeService = executeService;
this.objectMapper = objectMapper;
}
/**
* Registry Entry 하나를 MCP SDK의 stateless sync Tool specification으로 변환합니다.
*/
public McpStatelessServerFeatures.SyncToolSpecification create(ToolMetadata entry) {
McpSchema.Tool tool = McpSchema.Tool.builder()
.name(entry.getName())
.description(description(entry))
.inputSchema(inputSchema(entry))
.build();
return McpStatelessServerFeatures.SyncToolSpecification.builder()
.tool(tool)
.callHandler((context, request) -> execute(entry.getName(), request))
.build();
}
private McpSchema.CallToolResult execute(String toolName, McpSchema.CallToolRequest request) {
try {
// Build legacy JSON-RPC payload format expected by ExecuteService
Map<String, Object> payload = new HashMap<>();
payload.put("jsonrpc", "2.0");
payload.put("method", "tools/call");
payload.put("id", UUID.randomUUID().toString());
Map<String, Object> params = new HashMap<>();
params.put("name", toolName);
params.put("arguments", request.arguments());
payload.put("params", params);
// Execute through ExecuteService
Object rawResult = executeService.execute(payload, "system");
// Convert JSON-RPC response back to McpSchema.CallToolResult
return toCallToolResult(rawResult);
} catch (Exception error) {
return McpSchema.CallToolResult.builder()
.addTextContent("Tool 실행 중 내부 오류가 발생했습니다: " + error.getMessage())
.isError(true)
.build();
}
}
private McpSchema.CallToolResult toCallToolResult(Object rawResult) {
try {
Map<String, Object> resultMap = objectMapper.convertValue(rawResult, new TypeReference<Map<String, Object>>() {});
Object innerResult = resultMap.get("result");
McpSchema.CallToolResult.Builder builder = McpSchema.CallToolResult.builder();
builder.isError(resultMap.containsKey("error"));
if (innerResult instanceof Map) {
Map<String, Object> innerMap = (Map<String, Object>) innerResult;
// If it follows structured content
if (innerMap.containsKey("content")) {
List<Map<String, Object>> contentList = (List<Map<String, Object>>) innerMap.get("content");
for (Map<String, Object> item : contentList) {
if ("text".equals(item.get("type"))) {
builder.addTextContent((String) item.get("text"));
}
}
} else {
// Fallback to text string representation
builder.addTextContent(objectMapper.writeValueAsString(innerResult));
}
} else {
builder.addTextContent(String.valueOf(innerResult));
}
return builder.build();
} catch (Exception e) {
return McpSchema.CallToolResult.builder()
.addTextContent(String.valueOf(rawResult))
.build();
}
}
private Map<String, Object> inputSchema(ToolMetadata entry) {
if (entry.getParametersSchema() != null) {
return entry.getParametersSchema();
}
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", new HashMap<>());
schema.put("additionalProperties", false);
return schema;
}
private String description(ToolMetadata entry) {
return entry.getDescription() == null || entry.getDescription().isBlank()
? entry.getName() + " Tool"
: entry.getDescription();
}
}

View File

@@ -0,0 +1,87 @@
package io.shinhanlife.axhub.biz.mcp.gateway.sync;
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
import io.modelcontextprotocol.server.McpStatelessSyncServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
/**
* MCP 서버의 tools/list 노출 목록을 Redis Registry의 ACTIVE Tool 목록과 동기화합니다.
*/
@Service
public class RegistryMcpToolSynchronizer {
private static final Logger log = LoggerFactory.getLogger(RegistryMcpToolSynchronizer.class);
private final ObjectProvider<McpStatelessSyncServer> mcpServerProvider;
private final RedisRegistryService redisRegistryService;
private final RegistryMcpToolSpecificationFactory specificationFactory;
private final Set<String> managedToolNames = new LinkedHashSet<>();
private final ReentrantLock lock = new ReentrantLock();
public RegistryMcpToolSynchronizer(ObjectProvider<McpStatelessSyncServer> mcpServerProvider,
RedisRegistryService redisRegistryService,
RegistryMcpToolSpecificationFactory specificationFactory) {
this.mcpServerProvider = mcpServerProvider;
this.redisRegistryService = redisRegistryService;
this.specificationFactory = specificationFactory;
}
@EventListener(ApplicationReadyEvent.class)
public void synchronizeOnStartup() {
synchronize();
}
@Scheduled(fixedDelayString = "${mcp.tool-registry-sync-interval-millis:5000}")
public void synchronizeScheduled() {
synchronize();
}
public void synchronize() {
McpStatelessSyncServer server = mcpServerProvider.getIfAvailable();
if (server == null) {
return;
}
lock.lock();
try {
List<ToolMetadata> activeEntries = redisRegistryService.getAllTools()
.stream().filter(ToolMetadata::getVisible).collect(Collectors.toList());
Set<String> activeNames = activeEntries.stream()
.map(ToolMetadata::getName) // We use 'name' for MCP Tool Name
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<String> 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);
}
for (ToolMetadata entry : activeEntries) {
if (managedToolNames.contains(entry.getName())) {
server.removeTool(entry.getName());
}
server.addTool(specificationFactory.create(entry));
managedToolNames.add(entry.getName());
}
} catch (Exception error) {
log.warn("MCP registry tool synchronization failed. message={}", error.getMessage());
} finally {
lock.unlock();
}
}
}

View File

@@ -28,3 +28,15 @@ server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
# Suppress Kafka Connection Logs
logging.level.org.apache.kafka=ERROR
# Spring AI MCP Server Settings
spring.ai.mcp.server.protocol=STATELESS
mcp.agent-claims-required=false
mcp.trusted-claims-required=false
mcp.write-approval-required=false
mcp.default-page-size=100
mcp.max-page-size=500
mcp.max-pages-per-call=3
mcp.max-stream-bytes=1048576
mcp.max-response-bytes-from-tool=5242880
mcp.tool-registry-sync-interval-millis=5000