refactor: Migrate MCP Gateway to Spring AI MCP Server
This commit is contained in:
@@ -8,6 +8,9 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
// Spring AI MCP Server
|
||||
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
||||
|
||||
// MyBatis & DB
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user