feat: use MCP SDK for tool pod calls
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s

This commit is contained in:
jade
2026-07-29 23:03:11 +09:00
parent 0a2464d426
commit 5fe8590695
11 changed files with 254 additions and 74 deletions

View File

@@ -261,7 +261,6 @@ public class ExecuteService {
if (metadata.getPodUrl() != null && !metadata.getPodUrl().isEmpty()) {
targetUrl = metadata.getPodUrl();
}
String executeApiUrl = targetUrl + "/mcp/" + metadata.getName();
Map<String, String> headers = new java.util.HashMap<>();
headers.put("trace-id", context.requestId());
@@ -279,16 +278,16 @@ public class ExecuteService {
JsonNode data = null;
try {
data = toolInvoker.invoke(pagePayload, executeApiUrl, headers);
data = toolInvoker.invoke(metadata.getName(), pagePayload, targetUrl, headers);
} catch (org.springframework.web.client.RestClientResponseException e) {
// HTTP 4xx, 5xx 에러는 연결 오류가 아니라 비즈니스 로직 오류이거나 검증 실패이므로 원본 에러를 그대로 반환
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 에러 (" + e.getStatusCode() + "): " + e.getResponseBodyAsString());
} catch (Exception e) {
if (executeApiUrl.contains("http://tool-")) {
String fallbackUrl = executeApiUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
if (targetUrl.contains("http://tool-")) {
String fallbackUrl = targetUrl.replaceAll("http://tool-[a-zA-Z0-9-]+", "http://localhost");
log.warn(" [ExecuteService] 호스트를 찾을 수 없어 localhost로 재시도합니다: {}", fallbackUrl);
try {
data = toolInvoker.invoke(pagePayload, fallbackUrl, headers);
data = toolInvoker.invoke(metadata.getName(), pagePayload, fallbackUrl, headers);
} catch (Exception ex) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod 호출 실패 (localhost 재시도 포함): " + ex.getMessage());
}
@@ -385,4 +384,4 @@ public class ExecuteService {
public void shutdown() {
executor.shutdownNow();
}
}
}

View File

@@ -1,69 +1,81 @@
package io.shinhanlife.dap.mcg.transport;
/**
* @package io.shinhanlife.dap.mcg.transport
* @className HttpToolInvoker
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import com.fasterxml.jackson.databind.node.TextNode;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcg.resilience.FailureType;
import io.shinhanlife.dap.mcg.resilience.ToolExecutionException;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/** MCP SDK client used for Gateway-to-Tool-Pod calls over Streamable HTTP. */
@Slf4j
@Component
public class HttpToolInvoker implements ToolInvoker {
private final RestClient restClient;
private final ObjectMapper objectMapper;
public HttpToolInvoker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.restClient = RestClient.create();
}
@Override
public JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers) {
try {
RestClient.RequestBodySpec requestSpec = restClient.post()
.uri(targetUrl)
.contentType(MediaType.APPLICATION_JSON);
if (headers != null) {
headers.forEach(requestSpec::header);
public JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers) {
String endpoint = podUrl.replaceAll("/+$", "") + "/mcp";
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
if (headers != null) {
headers.forEach(requestBuilder::header);
}
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(endpoint)
.requestBuilder(requestBuilder)
.connectTimeout(Duration.ofSeconds(5))
.build();
try (McpSyncClient client = McpClient.sync(transport)
.clientInfo(new McpSchema.Implementation("dap-gateway", "1.0.0"))
.requestTimeout(Duration.ofSeconds(30))
.build()) {
client.initialize();
McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder()
.name(toolName)
.arguments(arguments)
.build());
if (Boolean.TRUE.equals(result.isError())) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool Pod MCP error: " + textContent(result));
}
Object httpResult = requestSpec.body(payload)
.retrieve()
.body(Object.class);
return extractData(objectMapper.valueToTree(httpResult));
} catch (Exception e) {
throw new ToolExecutionException(FailureType.SERVER_ERROR, "Tool Pod HTTP 호출 실패: " + e.getMessage(), e);
return result.structuredContent() != null
? objectMapper.valueToTree(result.structuredContent())
: textContentAsJson(result);
} catch (ToolExecutionException error) {
throw error;
} catch (Exception error) {
throw new ToolExecutionException(FailureType.SERVER_ERROR,
"Tool Pod MCP call failed: " + error.getMessage(), error);
}
}
private JsonNode extractData(JsonNode root) {
if (!root.path("success").asBoolean(true)) {
throw new ToolExecutionException(FailureType.BUSINESS_ERROR, "Tool 서버 업무 오류: " + root.path("error").asText());
private JsonNode textContentAsJson(McpSchema.CallToolResult result) {
String text = textContent(result);
try {
return objectMapper.readTree(text);
} catch (Exception ignored) {
return TextNode.valueOf(text);
}
return root.has("data") ? root.get("data") : root;
}
private String textContent(McpSchema.CallToolResult result) {
return result.content().stream()
.filter(McpSchema.TextContent.class::isInstance)
.map(McpSchema.TextContent.class::cast)
.map(McpSchema.TextContent::text)
.findFirst()
.orElse("");
}
}

View File

@@ -22,5 +22,5 @@ import java.util.Map;
* Tool 서버 호출 transport의 최소 공통 인터페이스입니다.
*/
public interface ToolInvoker {
JsonNode invoke(Map<String, Object> payload, String targetUrl, Map<String, String> headers);
JsonNode invoke(String toolName, Map<String, Object> arguments, String podUrl, Map<String, String> headers);
}

View File

@@ -4,6 +4,7 @@ plugins {
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-data-redis'
@@ -24,7 +25,7 @@ dependencies {
api 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.1'
api 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
api 'com.networknt:json-schema-validator:1.4.0'
api 'com.networknt:json-schema-validator:3.0.0'
api 'org.springframework.kafka:spring-kafka:3.2.0'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
}

View File

@@ -0,0 +1,26 @@
package io.shinhanlife.dap.mcc.mcp;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/** Exposes every Tool Pod through the MCP Streamable HTTP transport. */
@Configuration
public class ToolMcpServerConfiguration {
@Bean
@Primary
public HttpServletStreamableServerTransportProvider toolMcpTransportProvider() {
return HttpServletStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.build();
}
@Bean
public ServletRegistrationBean<HttpServletStreamableServerTransportProvider> toolMcpServlet(
HttpServletStreamableServerTransportProvider transportProvider) {
return new ServletRegistrationBean<>(transportProvider, "/mcp");
}
}

View File

@@ -0,0 +1,74 @@
package io.shinhanlife.dap.mcc.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import io.shinhanlife.dap.mcc.usecase.ToolRegistryHeartbeatSender;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */
@Component
public class ToolPodMcpToolSynchronizer {
private final McpSyncServer mcpServer;
private final ToolRegistryHeartbeatSender heartbeatSender;
private final BusinessToolController businessToolController;
private final ObjectMapper objectMapper;
public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender,
BusinessToolController businessToolController, ObjectMapper objectMapper) {
this.mcpServer = mcpServer;
this.heartbeatSender = heartbeatSender;
this.businessToolController = businessToolController;
this.objectMapper = objectMapper;
}
@EventListener(ApplicationReadyEvent.class)
public void registerLocalTools() {
heartbeatSender.getAllScannedTools().stream()
.filter(tool -> Boolean.TRUE.equals(tool.getVisible()))
.forEach(tool -> mcpServer.addTool(specification(tool)));
}
private McpServerFeatures.SyncToolSpecification specification(ToolMetadata tool) {
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema())
.annotations(McpSchema.ToolAnnotations.builder()
.readOnlyHint(Boolean.TRUE.equals(tool.getReadOnlyHint()))
.destructiveHint(Boolean.TRUE.equals(tool.getDestructiveHint()))
.idempotentHint(Boolean.TRUE.equals(tool.getIdempotentHint()))
.openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build())
.build();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), request.arguments())).build();
}
private McpSchema.CallToolResult invoke(String toolName, Map<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool(toolName, null, null, null, arguments);
boolean failed = !response.getStatusCode().is2xxSuccessful();
Object body = response.getBody();
try {
return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body))
.structuredContent(body).isError(failed).build();
} catch (Exception error) {
return McpSchema.CallToolResult.builder().addTextContent(String.valueOf(body)).isError(failed).build();
}
}
private Map<String, Object> emptySchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
schema.put("properties", Map.of());
schema.put("additionalProperties", false);
return schema;
}
}

View File

@@ -16,10 +16,7 @@ package io.shinhanlife.dap.mcc.presentation;
* </pre>
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import com.networknt.schema.Error;
import io.shinhanlife.dap.lib.annotation.McpFunction;
import io.shinhanlife.dap.lib.annotation.McpTool;
import io.shinhanlife.dap.lib.config.McpProperties;
@@ -58,6 +55,7 @@ public class BusinessToolController {
private final ObjectMapper objectMapper;
private final McpProperties mcpProperties;
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
// 내부 조회용 로컬 Tool 목록 엔드포인트
@GetMapping("/mcp/api/v1/tools/local")
@@ -149,17 +147,12 @@ public class BusinessToolController {
if (!Map.class.isAssignableFrom(paramType)) {
try {
Map<String, Object> autoSchema = JsonSchemaGenerator.generateSchema(paramType);
String fullSchemaJson = objectMapper.writeValueAsString(autoSchema);
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(fullSchemaJson);
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(arguments));
List<Error> errors = toolArgumentSchemaValidator.validate(autoSchema, arguments);
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
List<String> errorMessages = new ArrayList<>();
for (ValidationMessage vm : errors) {
errorMessages.add(vm.getMessage());
for (Error validationError : errors) {
errorMessages.add(validationError.getMessage());
}
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "422");
@@ -229,4 +222,4 @@ public class BusinessToolController {
return ResponseEntity.status(502).body(errorBody);
}
}
}
}

View File

@@ -0,0 +1,28 @@
package io.shinhanlife.dap.mcc.presentation;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import com.networknt.schema.InputFormat;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */
@Component
public class ToolArgumentSchemaValidator {
private final ObjectMapper objectMapper;
public ToolArgumentSchemaValidator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public List<Error> validate(Map<String, Object> schemaDefinition, Map<String, Object> arguments) throws Exception {
SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7);
Schema schema = schemaRegistry.getSchema(objectMapper.writeValueAsString(schemaDefinition));
return schema.validate(objectMapper.writeValueAsString(arguments), InputFormat.JSON);
}
}

View File

@@ -5,15 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import com.networknt.schema.Error;
import com.networknt.schema.InputFormat;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import io.shinhanlife.dap.lib.annotation.McpParameter;
import io.shinhanlife.dap.lib.annotation.McpValidation;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
/**
@@ -65,10 +65,10 @@ class JsonSchemaGeneratorTest {
@Test
void validatorRejectsInvalidNestedValue() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7)
Schema schema = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7)
.getSchema(objectMapper.writeValueAsString(JsonSchemaGenerator.generateSchema(NestedRequest.class)));
Set<ValidationMessage> errors = schema.validate(objectMapper.valueToTree(Map.of(
"child", Map.of("businessDate", "2026-07-28"))));
List<Error> errors = schema.validate(objectMapper.writeValueAsString(Map.of(
"child", Map.of("businessDate", "2026-07-28"))), InputFormat.JSON);
assertFalse(errors.isEmpty());
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.mcc.mcp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
class ToolMcpServerConfigurationTest {
private final ToolMcpServerConfiguration configuration = new ToolMcpServerConfiguration();
@Test
void exposesOnlyExactMcpEndpointSoLegacyMcpApiPathsRemainAvailable() {
HttpServletStreamableServerTransportProvider transport = configuration.toolMcpTransportProvider();
ServletRegistrationBean<HttpServletStreamableServerTransportProvider> registration = configuration.toolMcpServlet(transport);
assertEquals("/mcp", registration.getUrlMappings().iterator().next());
assertFalse(registration.getUrlMappings().contains("/mcp/*"));
}
}

View File

@@ -0,0 +1,25 @@
package io.shinhanlife.dap.mcc.presentation;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ToolArgumentSchemaValidatorTest {
private final ToolArgumentSchemaValidator validator = new ToolArgumentSchemaValidator(new ObjectMapper());
@Test
void validatesDraft7SchemaWithTheRuntimeNetworkntVersion() throws Exception {
Map<String, Object> schema = Map.of(
"type", "object",
"properties", Map.of("name", Map.of("type", "string")),
"required", List.of("name"));
assertTrue(validator.validate(schema, Map.of("name", "Hong")).isEmpty());
assertFalse(validator.validate(schema, Map.of()).isEmpty());
}
}