feat: use MCP SDK for tool pod calls
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 2m18s
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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/*"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user