From 95d4194b346175cb03673ea4af20d7fe2d8959c2 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 18 Mar 2025 12:20:54 +0100 Subject: [PATCH 1/7] feat(mcp): Refactor MCP server API to use Specification pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename Registration classes to Specification (SyncToolRegistration → SyncToolSpecification) - Update transport classes to use Provider suffix (WebFluxSseServerTransport → WebFluxSseServerTransportProvider) - Add exchange parameter to handler methods for better context passing - Introduce McpBackwardCompatibility class to maintain backward compatibility - Update MCP Server documentation to reflect new API patterns - Add tests for backward compatibility The changes align with the MCP specification evolution while maintaining backward compatibility through deprecated APIs. Signed-off-by: Christian Tzolov --- .../McpBackwardCompatibility.java | 116 +++++++ .../McpServerAutoConfiguration.java | 154 +++++---- .../McpWebFluxServerAutoConfiguration.java | 24 +- .../McpWebMvcServerAutoConfiguration.java | 19 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 - ...oConfigurationBackwardCompatibilityIT.java | 318 ++++++++++++++++++ .../McpServerAutoConfigurationIT.java | 61 ++-- .../springframework/ai/mcp/McpToolUtils.java | 227 ++++++++++++- .../ai/mcp/ToolUtilsDeprecatedTests.java | 178 ++++++++++ .../ai/mcp/ToolUtilsTests.java | 104 +++--- .../ROOT/pages/api/mcp/mcp-helpers.adoc | 20 +- .../api/mcp/mcp-server-boot-starter-docs.adoc | 48 +-- 12 files changed, 1059 insertions(+), 211 deletions(-) create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java create mode 100644 mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsDeprecatedTests.java diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java new file mode 100644 index 00000000000..7981172e5bf --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java @@ -0,0 +1,116 @@ +/* +* Copyright 2025 - 2025 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package org.springframework.ai.autoconfigure.mcp.server; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.CollectionUtils; + +/** + * @author Christian Tzolov + */ +@Deprecated +@Configuration +public class McpBackwardCompatibility { + + @Bean + public List>> syncRootsChangeConsumerToHandler( + List>> rootsChangeConsumers) { + + if (CollectionUtils.isEmpty(rootsChangeConsumers)) { + return List.of(); + } + + return rootsChangeConsumers.stream().map(c -> new BiConsumer>() { + @Override + public void accept(McpSyncServerExchange exchange, List roots) { + c.accept(roots); + } + }).collect(Collectors.toList()); + } + + @Bean + public List syncToolsRegistrationToSpecificaiton( + ObjectProvider> toolRegistrations) { + + return toolRegistrations.stream() + .flatMap(List::stream) + .map(McpServerFeatures.SyncToolRegistration::toSpecification) + .toList(); + } + + @Bean + public List syncResourceRegistrationToSpecificaiton( + ObjectProvider> resourceRegistrations) { + + return resourceRegistrations.stream() + .flatMap(List::stream) + .map(McpServerFeatures.SyncResourceRegistration::toSpecification) + .toList(); + } + + @Bean + public List syncPromptRegistrationToSpecificaiton( + ObjectProvider> promptRegistrations) { + + return promptRegistrations.stream() + .flatMap(List::stream) + .map(McpServerFeatures.SyncPromptRegistration::toSpecification) + .toList(); + } + + // Async + @Bean + public List asyncToolsRegistrationToSpecificaiton( + ObjectProvider> toolRegistrations) { + + return toolRegistrations.stream() + .flatMap(List::stream) + .map(McpServerFeatures.AsyncToolRegistration::toSpecification) + .toList(); + } + + @Bean + public List asyncResourceRegistrationToSpecificaiton( + ObjectProvider> resourceRegistrations) { + + return resourceRegistrations.stream() + .flatMap(List::stream) + .map(McpServerFeatures.AsyncResourceRegistration::toSpecification) + .toList(); + } + + @Bean + public List asyncPromptRegistrationToSpecificaiton( + ObjectProvider> promptRegistrations) { + + return promptRegistrations.stream() + .flatMap(List::stream) + .map(McpServerFeatures.AsyncPromptRegistration::toSpecification) + .toList(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java index ae8e86ef5a0..ae034000928 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java @@ -18,25 +18,27 @@ import java.util.ArrayList; import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpServer.AsyncSpec; -import io.modelcontextprotocol.server.McpServer.SyncSpec; +import io.modelcontextprotocol.server.McpServer.AsyncSpecification; +import io.modelcontextprotocol.server.McpServer.SyncSpecification; import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpServerFeatures.AsyncPromptRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.StdioServerTransport; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.Implementation; -import io.modelcontextprotocol.spec.ServerMcpTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import reactor.core.publisher.Mono; import org.springframework.ai.mcp.McpToolUtils; @@ -50,6 +52,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.core.log.LogAccessor; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; @@ -94,17 +97,19 @@ * *

* WebMvc transport support is provided separately by - * {@link McpWebMvcServerAutoConfiguration}. + * {@link McpWebFluxServerAutoConfiguration}. * * @author Christian Tzolov * @since 1.0.0 * @see McpServerProperties * @see McpWebMvcServerAutoConfiguration + * @see McpWebFluxServerAutoConfiguration * @see ToolCallback */ @AutoConfiguration(after = { McpWebMvcServerAutoConfiguration.class, McpWebFluxServerAutoConfiguration.class }) @ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) @EnableConfigurationProperties(McpServerProperties.class) +@Import(McpBackwardCompatibility.class) @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) public class McpServerAutoConfiguration { @@ -113,8 +118,8 @@ public class McpServerAutoConfiguration { @Bean @ConditionalOnMissingBean - public ServerMcpTransport stdioServerTransport() { - return new StdioServerTransport(); + public McpServerTransportProvider stdioServerTransport() { + return new StdioServerTransportProvider(); } @Bean @@ -126,40 +131,46 @@ public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() { @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", matchIfMissing = true) - public List syncTools(ObjectProvider> toolCalls, - McpServerProperties serverProperties) { - List tools = toolCalls.stream().flatMap(List::stream).toList(); + public List syncTools(ObjectProvider> toolCalls, + List toolCallbacks2, McpServerProperties serverProperties) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolCallbacks2)) { + tools.addAll(toolCallbacks2); + } - return this.toSyncToolRegistration(tools, serverProperties); + return this.toSyncToolSpecifications(tools, serverProperties); } - private List toSyncToolRegistration(List tools, + private List toSyncToolSpecifications(List tools, McpServerProperties serverProperties) { return tools.stream().map(tool -> { String toolName = tool.getToolDefinition().name(); MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; - return McpToolUtils.toSyncToolRegistration(tool, mimeType); + return McpToolUtils.toSyncToolSpecification(tool, mimeType); }).toList(); } @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", matchIfMissing = true) - public McpSyncServer mcpSyncServer(ServerMcpTransport transport, + public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider, McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpServerProperties serverProperties, - ObjectProvider> tools, ObjectProvider> resources, - ObjectProvider> prompts, - ObjectProvider>> rootsChangeConsumers, + ObjectProvider> tools, + ObjectProvider> resources, + ObjectProvider> prompts, + ObjectProvider>> rootsChangeConsumers, List toolCallbackProvider) { McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), serverProperties.getVersion()); // Create the server with both tool and resource capabilities - SyncSpec serverBuilder = McpServer.sync(transport).serverInfo(serverInfo); + SyncSpecification serverBuilder = McpServer.sync(transportProvider).serverInfo(serverInfo); - List toolRegistrations = new ArrayList<>(tools.stream().flatMap(List::stream).toList()); + List toolSpecifications = new ArrayList<>(tools.stream().flatMap(List::stream).toList()); List providerToolCallbacks = toolCallbackProvider.stream() .map(pr -> List.of(pr.getToolCallbacks())) @@ -168,33 +179,35 @@ public McpSyncServer mcpSyncServer(ServerMcpTransport transport, .map(fc -> (ToolCallback) fc) .toList(); - toolRegistrations.addAll(this.toSyncToolRegistration(providerToolCallbacks, serverProperties)); + toolSpecifications.addAll(this.toSyncToolSpecifications(providerToolCallbacks, serverProperties)); - if (!CollectionUtils.isEmpty(toolRegistrations)) { - serverBuilder.tools(toolRegistrations); + if (!CollectionUtils.isEmpty(toolSpecifications)) { + serverBuilder.tools(toolSpecifications); capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); - logger.info("Registered tools: " + toolRegistrations.size() + ", notification: " + logger.info("Registered tools: " + toolSpecifications.size() + ", notification: " + serverProperties.isToolChangeNotification()); } - List resourceRegistrations = resources.stream().flatMap(List::stream).toList(); - if (!CollectionUtils.isEmpty(resourceRegistrations)) { - serverBuilder.resources(resourceRegistrations); + List resourceSpecifications = resources.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(resourceSpecifications)) { + serverBuilder.resources(resourceSpecifications); capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification()); - logger.info("Registered resources: " + resourceRegistrations.size() + ", notification: " + logger.info("Registered resources: " + resourceSpecifications.size() + ", notification: " + serverProperties.isResourceChangeNotification()); } - List promptRegistrations = prompts.stream().flatMap(List::stream).toList(); - if (!CollectionUtils.isEmpty(promptRegistrations)) { - serverBuilder.prompts(promptRegistrations); + List promptSpecifications = prompts.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(promptSpecifications)) { + serverBuilder.prompts(promptSpecifications); capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification()); - logger.info("Registered prompts: " + promptRegistrations.size() + ", notification: " + logger.info("Registered prompts: " + promptSpecifications.size() + ", notification: " + serverProperties.isPromptChangeNotification()); } rootsChangeConsumers.ifAvailable(consumer -> { - serverBuilder.rootsChangeConsumer(consumer); + serverBuilder.rootsChangeHandler((exchange, roots) -> { + consumer.accept(exchange, roots); + }); logger.info("Registered roots change consumer"); }); @@ -205,40 +218,45 @@ public McpSyncServer mcpSyncServer(ServerMcpTransport transport, @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") - public List asyncTools(ObjectProvider> toolCalls, - McpServerProperties serverProperties) { - var tools = toolCalls.stream().flatMap(List::stream).toList(); + public List asyncTools(ObjectProvider> toolCalls, + List toolCallbacks2, McpServerProperties serverProperties) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + if (!CollectionUtils.isEmpty(toolCallbacks2)) { + tools.addAll(toolCallbacks2); + } - return this.toAsyncToolRegistration(tools, serverProperties); + return this.toAsyncToolSpecification(tools, serverProperties); } - private List toAsyncToolRegistration(List tools, + private List toAsyncToolSpecification(List tools, McpServerProperties serverProperties) { return tools.stream().map(tool -> { String toolName = tool.getToolDefinition().name(); MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; - return McpToolUtils.toAsyncToolRegistration(tool, mimeType); + return McpToolUtils.toAsyncToolSpecification(tool, mimeType); }).toList(); } @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") - public McpAsyncServer mcpAsyncServer(ServerMcpTransport transport, + public McpAsyncServer mcpAsyncServer(McpServerTransportProvider transportProvider, McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpServerProperties serverProperties, - ObjectProvider> tools, - ObjectProvider> resources, - ObjectProvider> prompts, - ObjectProvider>> rootsChangeConsumer, + ObjectProvider> tools, + ObjectProvider> resources, + ObjectProvider> prompts, + ObjectProvider>> rootsChangeConsumer, List toolCallbackProvider) { McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), serverProperties.getVersion()); // Create the server with both tool and resource capabilities - AsyncSpec serverBuilder = McpServer.async(transport).serverInfo(serverInfo); + AsyncSpecification serverBuilder = McpServer.async(transportProvider).serverInfo(serverInfo); - List toolRegistrations = new ArrayList<>(tools.stream().flatMap(List::stream).toList()); + List toolSpecifications = new ArrayList<>( + tools.stream().flatMap(List::stream).toList()); List providerToolCallbacks = toolCallbackProvider.stream() .map(pr -> List.of(pr.getToolCallbacks())) .flatMap(List::stream) @@ -246,37 +264,37 @@ public McpAsyncServer mcpAsyncServer(ServerMcpTransport transport, .map(fc -> (ToolCallback) fc) .toList(); - toolRegistrations.addAll(this.toAsyncToolRegistration(providerToolCallbacks, serverProperties)); + toolSpecifications.addAll(this.toAsyncToolSpecification(providerToolCallbacks, serverProperties)); - if (!CollectionUtils.isEmpty(toolRegistrations)) { - serverBuilder.tools(toolRegistrations); + if (!CollectionUtils.isEmpty(toolSpecifications)) { + serverBuilder.tools(toolSpecifications); capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); - logger.info("Registered tools: " + toolRegistrations.size() + ", notification: " + logger.info("Registered tools: " + toolSpecifications.size() + ", notification: " + serverProperties.isToolChangeNotification()); } - List resourceRegistrations = resources.stream().flatMap(List::stream).toList(); - if (!CollectionUtils.isEmpty(resourceRegistrations)) { - serverBuilder.resources(resourceRegistrations); + List resourceSpecifications = resources.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(resourceSpecifications)) { + serverBuilder.resources(resourceSpecifications); capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification()); - logger.info("Registered resources: " + resourceRegistrations.size() + ", notification: " + logger.info("Registered resources: " + resourceSpecifications.size() + ", notification: " + serverProperties.isResourceChangeNotification()); } - List promptRegistrations = prompts.stream().flatMap(List::stream).toList(); - if (!CollectionUtils.isEmpty(promptRegistrations)) { - serverBuilder.prompts(promptRegistrations); + List promptSpecifications = prompts.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(promptSpecifications)) { + serverBuilder.prompts(promptSpecifications); capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification()); - logger.info("Registered prompts: " + promptRegistrations.size() + ", notification: " + logger.info("Registered prompts: " + promptSpecifications.size() + ", notification: " + serverProperties.isPromptChangeNotification()); } rootsChangeConsumer.ifAvailable(consumer -> { - Function, Mono> asyncConsumer = roots -> { - consumer.accept(roots); + BiFunction, Mono> asyncConsumer = (exchange, roots) -> { + consumer.accept(exchange, roots); return Mono.empty(); }; - serverBuilder.rootsChangeConsumer(asyncConsumer); + serverBuilder.rootsChangeHandler(asyncConsumer); logger.info("Registered roots change consumer"); }); diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java index 81108cc2b74..9fbaa185ee6 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java @@ -18,6 +18,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.ServerMcpTransport; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -34,8 +37,8 @@ * server, providing reactive Server-Sent Events (SSE) communication through Spring * WebFlux. It is activated when: *

    - *
  • The WebFluxSseServerTransport class is on the classpath (from mcp-spring-webflux - * dependency)
  • + *
  • The WebFluxSseServerTransportProvider class is on the classpath (from + * mcp-spring-webflux dependency)
  • *
  • Spring WebFlux's RouterFunction class is available (from * spring-boot-starter-webflux)
  • *
  • The {@code spring.ai.mcp.server.transport} property is set to {@code WEBFLUX}
  • @@ -43,7 +46,8 @@ *

    * The configuration provides: *

      - *
    • A WebFluxSseServerTransport bean for handling reactive SSE communication
    • + *
    • A WebFluxSseServerTransportProvider bean for handling reactive SSE + * communication
    • *
    • A RouterFunction bean that sets up the reactive SSE endpoint
    • *
    *

    @@ -61,25 +65,25 @@ * @author Christian Tzolov * @since 1.0.0 * @see McpServerProperties - * @see WebFluxSseServerTransport + * @see WebFluxSseServerTransportProvider */ @AutoConfiguration -@ConditionalOnClass({ WebFluxSseServerTransport.class }) -@ConditionalOnMissingBean(ServerMcpTransport.class) +@ConditionalOnClass({ WebFluxSseServerTransportProvider.class }) +@ConditionalOnMissingBean(McpServerTransportProvider.class) @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "stdio", havingValue = "false", matchIfMissing = true) public class McpWebFluxServerAutoConfiguration { @Bean @ConditionalOnMissingBean - public WebFluxSseServerTransport webFluxTransport(McpServerProperties serverProperties) { - return new WebFluxSseServerTransport(new ObjectMapper(), serverProperties.getSseMessageEndpoint()); + public WebFluxSseServerTransportProvider webFluxTransport(McpServerProperties serverProperties) { + return new WebFluxSseServerTransportProvider(new ObjectMapper(), serverProperties.getSseMessageEndpoint()); } // Router function for SSE transport used by Spring WebFlux to start an HTTP server. @Bean - public RouterFunction webfluxMcpRouterFunction(WebFluxSseServerTransport webFluxTransport) { - return webFluxTransport.getRouterFunction(); + public RouterFunction webfluxMcpRouterFunction(WebFluxSseServerTransportProvider webFluxProvider) { + return webFluxProvider.getRouterFunction(); } } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java index d7cd68bf439..f0c63892005 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java @@ -17,8 +17,9 @@ package org.springframework.ai.mcp.server.autoconfigure; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransport; -import io.modelcontextprotocol.spec.ServerMcpTransport; +import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -57,25 +58,25 @@ * @author Christian Tzolov * @since 1.0.0 * @see McpServerProperties - * @see WebMvcSseServerTransport + * @see WebMvcSseServerTransportProvider */ @AutoConfiguration -@ConditionalOnClass({ WebMvcSseServerTransport.class }) -@ConditionalOnMissingBean(ServerMcpTransport.class) +@ConditionalOnClass({ WebMvcSseServerTransportProvider.class }) +@ConditionalOnMissingBean(McpServerTransportProvider.class) @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "stdio", havingValue = "false", matchIfMissing = true) public class McpWebMvcServerAutoConfiguration { @Bean @ConditionalOnMissingBean - public WebMvcSseServerTransport webMvcSseServerTransport(ObjectMapper objectMapper, + public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectMapper objectMapper, McpServerProperties serverProperties) { - return new WebMvcSseServerTransport(objectMapper, serverProperties.getSseMessageEndpoint()); + return new WebMvcSseServerTransportProvider(objectMapper, serverProperties.getSseMessageEndpoint()); } @Bean - public RouterFunction mvcMcpRouterFunction(WebMvcSseServerTransport transport) { - return transport.getRouterFunction(); + public RouterFunction mvcMcpRouterFunction(WebMvcSseServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); } } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d69e8625cd7..eec917912b7 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -17,4 +17,3 @@ org.springframework.ai.mcp.server.autoconfigure.McpServerAutoConfiguration org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration org.springframework.ai.mcp.server.autoconfigure.McpWebFluxServerAutoConfiguration - diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java new file mode 100644 index 00000000000..945a10b80d4 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java @@ -0,0 +1,318 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mcp.server; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.spec.ServerMcpTransport; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; + +import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@Deprecated +public class McpServerAutoConfigurationBackwardCompatibilityIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpServerAutoConfiguration.class)); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(McpSyncServer.class); + assertThat(context).hasSingleBean(McpServerTransportProvider.class); + assertThat(context.getBean(McpServerTransportProvider.class)) + .isInstanceOf(StdioServerTransportProvider.class); + + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.getName()).isEqualTo("mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.SYNC); + assertThat(properties.isToolChangeNotification()).isTrue(); + assertThat(properties.isResourceChangeNotification()).isTrue(); + assertThat(properties.isPromptChangeNotification()).isTrue(); + }); + } + + @Test + void asyncConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.name=test-server", + "spring.ai.mcp.server.version=2.0.0") + .run(context -> { + assertThat(context).hasSingleBean(McpAsyncServer.class); + assertThat(context).doesNotHaveBean(McpSyncServer.class); + + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-server"); + assertThat(properties.getVersion()).isEqualTo("2.0.0"); + assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.ASYNC); + }); + } + + @Test + void transportConfiguration() { + this.contextRunner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(McpServerTransport.class); + assertThat(context.getBean(McpServerTransport.class)).isInstanceOf(CustomServerTransport.class); + }); + } + + @Test + void serverNotificationConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.tool-change-notification=false", + "spring.ai.mcp.server.resource-change-notification=false") + .run(context -> { + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.isToolChangeNotification()).isFalse(); + assertThat(properties.isResourceChangeNotification()).isFalse(); + }); + } + + // @Test + void invalidConfigurationThrowsException() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.version=invalid-version").run(context -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure() + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid version format"); + }); + } + + @Test + void disabledConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(McpSyncServer.class); + assertThat(context).doesNotHaveBean(McpAsyncServer.class); + assertThat(context).doesNotHaveBean(ServerMcpTransport.class); + }); + } + + @Test + void notificationConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.tool-change-notification=false", + "spring.ai.mcp.server.resource-change-notification=false", + "spring.ai.mcp.server.prompt-change-notification=false") + .run(context -> { + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.isToolChangeNotification()).isFalse(); + assertThat(properties.isResourceChangeNotification()).isFalse(); + assertThat(properties.isPromptChangeNotification()).isFalse(); + }); + } + + @Test + void stdioConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stdio=true").run(context -> { + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.isStdio()).isTrue(); + }); + } + + @Test + void serverCapabilitiesConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(McpSchema.ServerCapabilities.Builder.class); + McpSchema.ServerCapabilities.Builder builder = context.getBean(McpSchema.ServerCapabilities.Builder.class); + assertThat(builder).isNotNull(); + }); + } + + @Test + void toolRegistrationConfiguration() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + List tools = context.getBean("syncTools", List.class); + assertThat(tools).hasSize(2); + }); + } + + @Test + void resourceRegistrationConfiguration() { + this.contextRunner.withUserConfiguration(TestResourceConfiguration.class).run(context -> { + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void promptRegistrationConfiguration() { + this.contextRunner.withUserConfiguration(TestPromptConfiguration.class).run(context -> { + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void asyncToolRegistrationConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + List tools = context.getBean("asyncTools", List.class); + assertThat(tools).hasSize(2); + }); + } + + @Test + void customCapabilitiesBuilder() { + this.contextRunner.withUserConfiguration(CustomCapabilitiesConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(McpSchema.ServerCapabilities.Builder.class); + assertThat(context.getBean(McpSchema.ServerCapabilities.Builder.class)) + .isInstanceOf(CustomCapabilitiesBuilder.class); + }); + } + + @Test + void rootsChangeConsumerConfiguration() { + this.contextRunner.withUserConfiguration(TestRootsChangeConfiguration.class).run(context -> { + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Configuration + static class TestResourceConfiguration { + + @Bean + List testResources() { + return List.of(); + } + + } + + @Configuration + static class TestPromptConfiguration { + + @Bean + List testPrompts() { + return List.of(); + } + + } + + @Configuration + static class CustomCapabilitiesConfiguration { + + @Bean + McpSchema.ServerCapabilities.Builder customCapabilitiesBuilder() { + return new CustomCapabilitiesBuilder(); + } + + } + + static class CustomCapabilitiesBuilder extends McpSchema.ServerCapabilities.Builder { + + // Custom implementation for testing + + } + + @Configuration + static class TestToolConfiguration { + + @Bean + List testTool() { + McpSyncClient mockClient = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool.name()).thenReturn("test-tool"); + Mockito.when(mockTool.description()).thenReturn("Test Tool"); + Mockito.when(mockClient.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult); + when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0")); + + return List.of(new SyncMcpToolCallback(mockClient, mockTool)); + } + + } + + @Configuration + static class TestRootsChangeConfiguration { + + @Bean + Consumer> rootsChangeConsumer() { + return roots -> { + // Test implementation + }; + } + + } + + static class CustomServerTransport implements McpServerTransport { + + @Override + public Mono connect( + Function, Mono> messageHandler) { + return Mono.empty(); // Test implementation + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.empty(); // Test implementation + } + + @Override + public T unmarshalFrom(Object value, TypeReference type) { + return null; // Test implementation + } + + @Override + public void close() { + // Test implementation + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); // Test implementation + } + + } + + @Configuration + static class CustomTransportConfiguration { + + @Bean + McpServerTransport customTransport() { + return new CustomServerTransport(); + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java index 84d08f6445b..29eff9e5d3a 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java @@ -16,30 +16,34 @@ package org.springframework.ai.mcp.server.autoconfigure; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + import com.fasterxml.jackson.core.type.TypeReference; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.server.McpAsyncServer; -import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.StdioServerTransport; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.ServerMcpTransport; -import org.mockito.Mockito; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; + import org.springframework.ai.mcp.SyncMcpToolCallback; import org.springframework.ai.tool.ToolCallback; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import reactor.core.publisher.Mono; - -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -53,8 +57,9 @@ public class McpServerAutoConfigurationIT { void defaultConfiguration() { this.contextRunner.run(context -> { assertThat(context).hasSingleBean(McpSyncServer.class); - assertThat(context).hasSingleBean(ServerMcpTransport.class); - assertThat(context.getBean(ServerMcpTransport.class)).isInstanceOf(StdioServerTransport.class); + assertThat(context).hasSingleBean(McpServerTransportProvider.class); + assertThat(context.getBean(McpServerTransportProvider.class)) + .isInstanceOf(StdioServerTransportProvider.class); McpServerProperties properties = context.getBean(McpServerProperties.class); assertThat(properties.getName()).isEqualTo("mcp-server"); @@ -85,8 +90,8 @@ void asyncConfiguration() { @Test void transportConfiguration() { this.contextRunner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> { - assertThat(context).hasSingleBean(ServerMcpTransport.class); - assertThat(context.getBean(ServerMcpTransport.class)).isInstanceOf(CustomServerTransport.class); + assertThat(context).hasSingleBean(McpServerTransport.class); + assertThat(context.getBean(McpServerTransport.class)).isInstanceOf(CustomServerTransport.class); }); } @@ -155,8 +160,8 @@ void serverCapabilitiesConfiguration() { @Test void toolRegistrationConfiguration() { this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { - List tools = context.getBean("syncTools", List.class); - assertThat(tools).hasSize(1); + List tools = context.getBean("syncTools", List.class); + assertThat(tools).hasSize(2); }); } @@ -181,8 +186,8 @@ void asyncToolRegistrationConfiguration() { this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC") .withUserConfiguration(TestToolConfiguration.class) .run(context -> { - List tools = context.getBean("asyncTools", List.class); - assertThat(tools).hasSize(1); + List tools = context.getBean("asyncTools", List.class); + assertThat(tools).hasSize(2); }); } @@ -196,8 +201,8 @@ void customCapabilitiesBuilder() { } @Test - void rootsChangeConsumerConfiguration() { - this.contextRunner.withUserConfiguration(TestRootsChangeConfiguration.class).run(context -> { + void rootsChangeHandlerConfiguration() { + this.contextRunner.withUserConfiguration(TestRootsHandlerConfiguration.class).run(context -> { McpSyncServer server = context.getBean(McpSyncServer.class); assertThat(server).isNotNull(); }); @@ -207,7 +212,7 @@ void rootsChangeConsumerConfiguration() { static class TestResourceConfiguration { @Bean - List testResources() { + List testResources() { return List.of(); } @@ -217,7 +222,7 @@ List testResources() { static class TestPromptConfiguration { @Bean - List testPrompts() { + List testPrompts() { return List.of(); } @@ -259,18 +264,18 @@ List testTool() { } @Configuration - static class TestRootsChangeConfiguration { + static class TestRootsHandlerConfiguration { @Bean - Consumer> rootsChangeConsumer() { - return roots -> { + BiConsumer> rootsChangeHandler() { + return (exchange, roots) -> { // Test implementation }; } } - static class CustomServerTransport implements ServerMcpTransport { + static class CustomServerTransport implements McpServerTransport { @Override public Mono connect( @@ -304,7 +309,7 @@ public Mono closeGracefully() { static class CustomTransportConfiguration { @Bean - ServerMcpTransport customTransport() { + McpServerTransport customTransport() { return new CustomServerTransport(); } diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java index 5925f1ebfbe..4d5b7983417 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java @@ -16,17 +16,21 @@ package org.springframework.ai.mcp; import java.util.List; +import java.util.Map; import io.micrometer.common.util.StringUtils; import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.Role; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.tool.ToolCallback; import org.springframework.util.CollectionUtils; @@ -86,12 +90,29 @@ public static String prefixedToolName(String prefix, String toolName) { * @param toolCallbacks the list of tool callbacks to convert * @return a list of MCP synchronous tool registrations * @see #toSyncToolRegistration(ToolCallback) + * @deprecated Use {@link #toSyncToolSpecification(List)} instead. */ + @Deprecated public static List toSyncToolRegistration( List toolCallbacks) { return toolCallbacks.stream().map(McpToolUtils::toSyncToolRegistration).toList(); } + /** + * Converts a list of Spring AI tool callbacks to MCP synchronous tool specificaiton. + *

    + * This method processes multiple tool callbacks in bulk, converting each one to its + * corresponding MCP tool registration while maintaining synchronous execution + * semantics. + * @param toolCallbacks the list of tool callbacks to convert + * @return a list of MCP synchronous tool specificaiton + * @see #toSyncToolRegistration(ToolCallback) + */ + public static List toSyncToolSpecification( + List toolCallbacks) { + return toolCallbacks.stream().map(McpToolUtils::toSyncToolSpecification).toList(); + } + /** * Convenience method to convert a variable number of tool callbacks to MCP * synchronous tool registrations. @@ -101,13 +122,35 @@ public static List toSyncToolRegistratio * @param toolCallbacks the tool callbacks to convert * @return a list of MCP synchronous tool registrations * @see #toSyncToolRegistration(List) + * @deprecated Use {@link #toSyncToolSpecification(ToolCallback...)} instead. */ + @Deprecated + public static List toSyncToolRegistrations(ToolCallback... toolCallbacks) { + return toSyncToolRegistration(List.of(toolCallbacks)); + } + + @Deprecated public static List toSyncToolRegistration(ToolCallback... toolCallbacks) { return toSyncToolRegistration(List.of(toolCallbacks)); } /** - * Converts a Spring AI FunctionCallback to an MCP SyncToolRegistration. This enables + * Convenience method to convert a variable number of tool callbacks to MCP + * synchronous tool specification. + *

    + * This is a varargs wrapper around {@link #toSyncToolSpecification(List)} for easier + * usage when working with individual callbacks. + * @param toolCallbacks the tool callbacks to convert + * @return a list of MCP synchronous tool specificaiton + * @see #toSyncToolRegistration(List) + */ + public static List toSyncToolSpecifications( + ToolCallback... toolCallbacks) { + return toSyncToolSpecification(List.of(toolCallbacks)); + } + + /** + * Converts a Spring AI ToolCallback to an MCP SyncToolRegistration. This enables * Spring AI functions to be exposed as MCP tools that can be discovered and invoked * by language models. * @@ -121,18 +164,45 @@ public static List toSyncToolRegistratio * specifications *

* - * You can use the FunctionCallback builder to create a new instance of - * FunctionCallback using either java.util.function.Function or Method reference. + * You can use the ToolCallback builder to create a new instance of ToolCallback using + * either java.util.function.Function or Method reference. * @param toolCallback the Spring AI function callback to convert * @return an MCP SyncToolRegistration that wraps the function callback * @throws RuntimeException if there's an error during the function execution + * @deprecated Use {@link #toSyncToolSpecification(ToolCallback)} instead. */ + @Deprecated public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(ToolCallback toolCallback) { return toSyncToolRegistration(toolCallback, null); } /** - * Converts a Spring AI FunctionCallback to an MCP SyncToolRegistration. This enables + * Converts a Spring AI ToolCallback to an MCP SyncToolSpecification. This enables + * Spring AI functions to be exposed as MCP tools that can be discovered and invoked + * by language models. + * + *

+ * The conversion process: + *

    + *
  • Creates an MCP Tool with the function's name and input schema
  • + *
  • Wraps the function's execution in a SyncToolSpecification that handles the MCP + * protocol
  • + *
  • Provides error handling and result formatting according to MCP + * specifications
  • + *
+ * + * You can use the ToolCallback builder to create a new instance of ToolCallback using + * either java.util.function.Function or Method reference. + * @param toolCallback the Spring AI function callback to convert + * @return an MCP SyncToolSpecification that wraps the function callback + * @throws RuntimeException if there's an error during the function execution + */ + public static McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCallback toolCallback) { + return toSyncToolSpecification(toolCallback, null); + } + + /** + * Converts a Spring AI ToolCallback to an MCP SyncToolRegistration. This enables * Spring AI functions to be exposed as MCP tools that can be discovered and invoked * by language models. * @@ -146,13 +216,15 @@ public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(Tool * specifications * * - * You can use the FunctionCallback builder to create a new instance of - * FunctionCallback using either java.util.function.Function or Method reference. + * You can use the ToolCallback builder to create a new instance of ToolCallback using + * either java.util.function.Function or Method reference. * @param toolCallback the Spring AI function callback to convert * @param mimeType the MIME type of the output content * @return an MCP SyncToolRegistration that wraps the function callback * @throws RuntimeException if there's an error during the function execution + * @deprecated Use {@link #toSyncToolSpecification(ToolCallback, MimeType)} instead. */ + @Deprecated public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(ToolCallback toolCallback, MimeType mimeType) { @@ -175,6 +247,48 @@ public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(Tool }); } + /** + * Converts a Spring AI ToolCallback to an MCP SyncToolSpecification. This enables + * Spring AI functions to be exposed as MCP tools that can be discovered and invoked + * by language models. + * + *

+ * The conversion process: + *

    + *
  • Creates an MCP Tool with the function's name and input schema
  • + *
  • Wraps the function's execution in a SyncToolSpecification that handles the MCP + * protocol
  • + *
  • Provides error handling and result formatting according to MCP + * specifications
  • + *
+ * @param toolCallback the Spring AI function callback to convert + * @param mimeType the MIME type of the output content + * @return an MCP SyncToolRegistration that wraps the function callback + * @throws RuntimeException if there's an error during the function execution + */ + public static McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCallback toolCallback, + MimeType mimeType) { + + var tool = new McpSchema.Tool(toolCallback.getToolDefinition().name(), + toolCallback.getToolDefinition().description(), toolCallback.getToolDefinition().inputSchema()); + + return new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> { + try { + String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request), + new ToolContext(Map.of("exchange", exchange))); + if (mimeType != null && mimeType.toString().startsWith("image")) { + return new McpSchema.CallToolResult(List + .of(new McpSchema.ImageContent(List.of(Role.ASSISTANT), null, callResult, mimeType.toString())), + false); + } + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(callResult)), false); + } + catch (Exception e) { + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(e.getMessage())), true); + } + }); + } + /** * Converts a list of Spring AI tool callbacks to MCP asynchronous tool registrations. *

@@ -185,12 +299,30 @@ public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(Tool * @param toolCallbacks the list of tool callbacks to convert * @return a list of MCP asynchronous tool registrations * @see #toAsyncToolRegistration(ToolCallback) + * @deprecated Use {@link #toAsyncToolSpecification(List)} instead. */ + @Deprecated public static List toAsyncToolRegistration( List toolCallbacks) { return toolCallbacks.stream().map(McpToolUtils::toAsyncToolRegistration).toList(); } + /** + * Converts a list of Spring AI tool callbacks to MCP asynchronous tool specificaiton. + *

+ * This method processes multiple tool callbacks in bulk, converting each one to its + * corresponding MCP tool registration while adding asynchronous execution + * capabilities. The resulting registrations will execute their tools on a bounded + * elastic scheduler. + * @param toolCallbacks the list of tool callbacks to convert + * @return a list of MCP asynchronous tool specifications + * @see #toAsyncToolRegistration(ToolCallback) + */ + public static List toAsyncToolSpecifications( + List toolCallbacks) { + return toolCallbacks.stream().map(McpToolUtils::toAsyncToolSpecification).toList(); + } + /** * Convenience method to convert a variable number of tool callbacks to MCP * asynchronous tool registrations. @@ -205,6 +337,21 @@ public static List toAsyncToolRegistrat return toAsyncToolRegistration(List.of(toolCallbacks)); } + /** + * Convenience method to convert a variable number of tool callbacks to MCP + * asynchronous tool specificaiton. + *

+ * This is a varargs wrapper around {@link #toAsyncToolSpecifications(List)} for + * easier usage when working with individual callbacks. + * @param toolCallbacks the tool callbacks to convert + * @return a list of MCP asynchronous tool specifications + * @see #toAsyncToolSpecifications(List) + */ + public static List toAsyncToolSpecifications( + ToolCallback... toolCallbacks) { + return toAsyncToolSpecifications(List.of(toolCallbacks)); + } + /** * Converts a Spring AI tool callback to an MCP asynchronous tool registration. *

@@ -228,11 +375,41 @@ public static List toAsyncToolRegistrat * @see McpServerFeatures.AsyncToolRegistration * @see Mono * @see Schedulers#boundedElastic() + * @deprecated Use {@link #toAsyncToolSpecification(ToolCallback)} instead. */ + @Deprecated public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(ToolCallback toolCallback) { return toAsyncToolRegistration(toolCallback, null); } + /** + * Converts a Spring AI tool callback to an MCP asynchronous tool registration. + *

+ * This method enables Spring AI tools to be exposed as asynchronous MCP tools that + * can be discovered and invoked by language models. The conversion process: + *

    + *
  • First converts the callback to a synchronous registration
  • + *
  • Wraps the synchronous execution in a reactive Mono
  • + *
  • Configures execution on a bounded elastic scheduler for non-blocking + * operation
  • + *
+ *

+ * The resulting async registration will: + *

    + *
  • Execute the tool without blocking the calling thread
  • + *
  • Handle errors and results asynchronously
  • + *
  • Provide backpressure through Project Reactor
  • + *
+ * @param toolCallback the Spring AI tool callback to convert + * @return an MCP asynchronous tool registration that wraps the tool callback + * @see McpServerFeatures.AsyncToolRegistration + * @see Mono + * @see Schedulers#boundedElastic() + */ + public static McpServerFeatures.AsyncToolSpecification toAsyncToolSpecification(ToolCallback toolCallback) { + return toAsyncToolSpecification(toolCallback, null); + } + /** * Converts a Spring AI tool callback to an MCP asynchronous tool registration. *

@@ -257,7 +434,9 @@ public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(To * @see McpServerFeatures.AsyncToolRegistration * @see Mono * @see Schedulers#boundedElastic() + * @deprecated Use {@link #toAsyncToolSpecification(ToolCallback, MimeType)} instead. */ + @Deprecated public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(ToolCallback toolCallback, MimeType mimeType) { @@ -268,6 +447,42 @@ public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(To .subscribeOn(Schedulers.boundedElastic())); } + /** + * Converts a Spring AI tool callback to an MCP asynchronous tool specification. + *

+ * This method enables Spring AI tools to be exposed as asynchronous MCP tools that + * can be discovered and invoked by language models. The conversion process: + *

    + *
  • First converts the callback to a synchronous specificaiton
  • + *
  • Wraps the synchronous execution in a reactive Mono
  • + *
  • Configures execution on a bounded elastic scheduler for non-blocking + * operation
  • + *
+ *

+ * The resulting async specificaiton will: + *

    + *
  • Execute the tool without blocking the calling thread
  • + *
  • Handle errors and results asynchronously
  • + *
  • Provide backpressure through Project Reactor
  • + *
+ * @param toolCallback the Spring AI tool callback to convert + * @param mimeType the MIME type of the output content + * @return an MCP asynchronous tool specificaiotn that wraps the tool callback + * @see McpServerFeatures.AsyncToolSpecification + * @see Mono + * @see Schedulers#boundedElastic() + */ + public static McpServerFeatures.AsyncToolSpecification toAsyncToolSpecification(ToolCallback toolCallback, + MimeType mimeType) { + + McpServerFeatures.SyncToolSpecification syncToolSpecification = toSyncToolSpecification(toolCallback, mimeType); + + return new AsyncToolSpecification(syncToolSpecification.tool(), + (exchange, map) -> Mono + .fromCallable(() -> syncToolSpecification.call().apply(new McpSyncServerExchange(exchange), map)) + .subscribeOn(Schedulers.boundedElastic())); + } + /** * Convenience method to get tool callbacks from multiple synchronous MCP clients. *

diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsDeprecatedTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsDeprecatedTests.java new file mode 100644 index 00000000000..2a7c8e0677e --- /dev/null +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsDeprecatedTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @deprecated used to test backward compatbility. Replaced by the {@link ToolUtilsTests} + * instead + */ +@Deprecated +class ToolUtilsDeprecatedTests { + + @Test + void constructorShouldBePrivate() throws Exception { + Constructor constructor = McpToolUtils.class.getDeclaredConstructor(); + assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue(); + constructor.setAccessible(true); + constructor.newInstance(); + } + + @Test + void toSyncToolRegistrationShouldConvertSingleCallback() { + // Arrange + ToolCallback callback = createMockToolCallback("test", "success"); + + // Act + SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + assertThat(registration.tool().name()).isEqualTo("test"); + + CallToolResult result = registration.call().apply(Map.of()); + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("success"); + assertThat(result.isError()).isFalse(); + } + + @Test + void toSyncToolRegistrationShouldHandleError() { + // Arrange + ToolCallback callback = createMockToolCallback("test", new RuntimeException("error")); + + // Act + SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + CallToolResult result = registration.call().apply(Map.of()); + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("error"); + assertThat(result.isError()).isTrue(); + } + + @Test + void toSyncToolRegistrationShouldConvertMultipleCallbacks() { + // Arrange + ToolCallback callback1 = createMockToolCallback("test1", "success1"); + ToolCallback callback2 = createMockToolCallback("test2", "success2"); + + // Act + List registrations = McpToolUtils.toSyncToolRegistrations(callback1, callback2); + + // Assert + assertThat(registrations).hasSize(2); + assertThat(registrations.get(0).tool().name()).isEqualTo("test1"); + assertThat(registrations.get(1).tool().name()).isEqualTo("test2"); + } + + @Test + void toAsyncToolRegistrationShouldConvertSingleCallback() { + // Arrange + ToolCallback callback = createMockToolCallback("test", "success"); + + // Act + AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + assertThat(registration.tool().name()).isEqualTo("test"); + + StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> { + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("success"); + assertThat(result.isError()).isFalse(); + }).verifyComplete(); + } + + @Test + void toAsyncToolRegistrationShouldHandleError() { + // Arrange + ToolCallback callback = createMockToolCallback("test", new RuntimeException("error")); + + // Act + AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> { + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("error"); + assertThat(result.isError()).isTrue(); + }).verifyComplete(); + } + + @Test + void toAsyncToolRegistrationShouldConvertMultipleCallbacks() { + // Arrange + ToolCallback callback1 = createMockToolCallback("test1", "success1"); + ToolCallback callback2 = createMockToolCallback("test2", "success2"); + + // Act + List registrations = McpToolUtils.toAsyncToolRegistration(callback1, callback2); + + // Assert + assertThat(registrations).hasSize(2); + assertThat(registrations.get(0).tool().name()).isEqualTo("test1"); + assertThat(registrations.get(1).tool().name()).isEqualTo("test2"); + } + + private ToolCallback createMockToolCallback(String name, String result) { + ToolCallback callback = mock(ToolCallback.class); + ToolDefinition definition = ToolDefinition.builder() + .name(name) + .description("Test tool") + .inputSchema("{}") + .build(); + when(callback.getToolDefinition()).thenReturn(definition); + when(callback.call(anyString())).thenReturn(result); + return callback; + } + + private ToolCallback createMockToolCallback(String name, RuntimeException error) { + ToolCallback callback = mock(ToolCallback.class); + ToolDefinition definition = ToolDefinition.builder() + .name(name) + .description("Test tool") + .inputSchema("{}") + .build(); + when(callback.getToolDefinition()).thenReturn(definition); + when(callback.call(anyString())).thenThrow(error); + return callback; + } + +} diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java index 18af850ad1f..75005a0740f 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java @@ -21,8 +21,10 @@ import java.util.List; import java.util.Map; -import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; -import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; @@ -32,6 +34,7 @@ import org.springframework.ai.tool.definition.ToolDefinition; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -47,103 +50,94 @@ void constructorShouldBePrivate() throws Exception { } @Test - void toSyncToolRegistrationShouldConvertSingleCallback() { - // Arrange + void toSyncToolSpecificaitonShouldConvertSingleCallback() { + ToolCallback callback = createMockToolCallback("test", "success"); - // Act - SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback); + SyncToolSpecification toolSpecification = McpToolUtils.toSyncToolSpecification(callback); - // Assert - assertThat(registration).isNotNull(); - assertThat(registration.tool().name()).isEqualTo("test"); + assertThat(toolSpecification).isNotNull(); + assertThat(toolSpecification.tool().name()).isEqualTo("test"); - CallToolResult result = registration.call().apply(Map.of()); + CallToolResult result = toolSpecification.call().apply(mock(McpSyncServerExchange.class), Map.of()); TextContent content = (TextContent) result.content().get(0); assertThat(content.text()).isEqualTo("success"); assertThat(result.isError()).isFalse(); } @Test - void toSyncToolRegistrationShouldHandleError() { - // Arrange + void toSyncToolSpecificationShouldHandleError() { ToolCallback callback = createMockToolCallback("test", new RuntimeException("error")); - // Act - SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback); + SyncToolSpecification toolSpecification = McpToolUtils.toSyncToolSpecification(callback); - // Assert - assertThat(registration).isNotNull(); - CallToolResult result = registration.call().apply(Map.of()); + assertThat(toolSpecification).isNotNull(); + CallToolResult result = toolSpecification.call().apply(mock(McpSyncServerExchange.class), Map.of()); TextContent content = (TextContent) result.content().get(0); assertThat(content.text()).isEqualTo("error"); assertThat(result.isError()).isTrue(); } @Test - void toSyncToolRegistrationShouldConvertMultipleCallbacks() { - // Arrange + void toSyncToolSpecificationShouldConvertMultipleCallbacks() { ToolCallback callback1 = createMockToolCallback("test1", "success1"); ToolCallback callback2 = createMockToolCallback("test2", "success2"); - // Act - List registrations = McpToolUtils.toSyncToolRegistration(callback1, callback2); + List toolSpecification = McpToolUtils.toSyncToolSpecifications(callback1, callback2); - // Assert - assertThat(registrations).hasSize(2); - assertThat(registrations.get(0).tool().name()).isEqualTo("test1"); - assertThat(registrations.get(1).tool().name()).isEqualTo("test2"); + assertThat(toolSpecification).hasSize(2); + assertThat(toolSpecification.get(0).tool().name()).isEqualTo("test1"); + assertThat(toolSpecification.get(1).tool().name()).isEqualTo("test2"); } @Test - void toAsyncToolRegistrationShouldConvertSingleCallback() { - // Arrange + void toAsyncToolSpecificaitonShouldConvertSingleCallback() { ToolCallback callback = createMockToolCallback("test", "success"); - // Act - AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback); + AsyncToolSpecification toolSpecification = McpToolUtils.toAsyncToolSpecification(callback); // Assert - assertThat(registration).isNotNull(); - assertThat(registration.tool().name()).isEqualTo("test"); - - StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> { - TextContent content = (TextContent) result.content().get(0); - assertThat(content.text()).isEqualTo("success"); - assertThat(result.isError()).isFalse(); - }).verifyComplete(); + assertThat(toolSpecification).isNotNull(); + assertThat(toolSpecification.tool().name()).isEqualTo("test"); + + StepVerifier.create(toolSpecification.call().apply(mock(McpAsyncServerExchange.class), Map.of())) + .assertNext(result -> { + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("success"); + assertThat(result.isError()).isFalse(); + }) + .verifyComplete(); } @Test - void toAsyncToolRegistrationShouldHandleError() { - // Arrange + void toAsyncToolSpecificationShouldHandleError() { ToolCallback callback = createMockToolCallback("test", new RuntimeException("error")); - // Act - AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback); + AsyncToolSpecification toolSpecificaiton = McpToolUtils.toAsyncToolSpecification(callback); - // Assert - assertThat(registration).isNotNull(); - StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> { - TextContent content = (TextContent) result.content().get(0); - assertThat(content.text()).isEqualTo("error"); - assertThat(result.isError()).isTrue(); - }).verifyComplete(); + assertThat(toolSpecificaiton).isNotNull(); + StepVerifier.create(toolSpecificaiton.call().apply(mock(McpAsyncServerExchange.class), Map.of())) + .assertNext(result -> { + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("error"); + assertThat(result.isError()).isTrue(); + }) + .verifyComplete(); } @Test - void toAsyncToolRegistrationShouldConvertMultipleCallbacks() { + void toAsyncToolSpecificationShouldConvertMultipleCallbacks() { // Arrange ToolCallback callback1 = createMockToolCallback("test1", "success1"); ToolCallback callback2 = createMockToolCallback("test2", "success2"); // Act - List registrations = McpToolUtils.toAsyncToolRegistration(callback1, callback2); + List toolSpecifications = McpToolUtils.toAsyncToolSpecifications(callback1, callback2); // Assert - assertThat(registrations).hasSize(2); - assertThat(registrations.get(0).tool().name()).isEqualTo("test1"); - assertThat(registrations.get(1).tool().name()).isEqualTo("test2"); + assertThat(toolSpecifications).hasSize(2); + assertThat(toolSpecifications.get(0).tool().name()).isEqualTo("test1"); + assertThat(toolSpecifications.get(1).tool().name()).isEqualTo("test2"); } private ToolCallback createMockToolCallback(String name, String result) { @@ -154,7 +148,7 @@ private ToolCallback createMockToolCallback(String name, String result) { .inputSchema("{}") .build(); when(callback.getToolDefinition()).thenReturn(definition); - when(callback.call(anyString())).thenReturn(result); + when(callback.call(anyString(), any())).thenReturn(result); return callback; } @@ -166,7 +160,7 @@ private ToolCallback createMockToolCallback(String name, RuntimeException error) .inputSchema("{}") .build(); when(callback.getToolDefinition()).thenReturn(definition); - when(callback.call(anyString())).thenThrow(error); + when(callback.call(anyString(), any())).thenThrow(error); return callback; } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-helpers.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-helpers.adoc index ce756511f40..800ddd067a8 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-helpers.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-helpers.adoc @@ -88,9 +88,9 @@ Flux callbacks = AsyncMcpToolCallbackProvider.asyncToolCallbacks(c == McpToolUtils -=== ToolCallbacks to ToolRegistrations +=== ToolCallbacks to ToolSpecifications -Converting Spring AI tool callbacks to MCP tool registrations: +Converting Spring AI tool callbacks to MCP tool specificaitons: [tabs] ====== @@ -99,15 +99,15 @@ Sync:: [source,java] ---- List toolCallbacks = // obtain tool callbacks -List syncToolRegs = McpToolUtils.toSyncToolRegistration(toolCallbacks); +List syncToolSpecs = McpToolUtils.toSyncToolSpecifications(toolCallbacks); ---- + -then you can use the `McpServer.SyncSpec` to register the tool registrations: +then you can use the `McpServer.SyncSpecification` to register the tool specifications: + [source,java] ---- -McpServer.SyncSpec syncSpec = ... -syncSpec.tools(syncToolRegs); +McpServer.SyncSpecificaiton syncSpec = ... +syncSpec.tools(syncToolSpecs); ---- Async:: @@ -115,15 +115,15 @@ Async:: [source,java] ---- List toolCallbacks = // obtain tool callbacks -List asyncToolRegs = McpToolUtils.toAsyncToolRegistration(toolCallbacks); +List asyncToolSpecificaitons = McpToolUtils.toAsyncToolSpecifications(toolCallbacks); ---- + -then you can use the `McpServer.AsyncSpec` to register the tool registrations: +then you can use the `McpServer.AsyncSpecification` to register the tool specifications: + [source,java] ---- -McpServer.AsyncSpec asyncSpec = ... -asyncSpec.tools(asyncToolRegs); +McpServer.AsyncSpecification asyncSpec = ... +asyncSpec.tools(asyncToolSpecificaitons); ---- ====== diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc index c8ffe56fcf3..3d4bfae6967 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc @@ -7,7 +7,7 @@ The MCP Server Boot Starter offers: * Automatic configuration of MCP server components * Support for both synchronous and asynchronous operation modes * Multiple transport layer options -* Flexible tool, resource, and prompt registration +* Flexible tool, resource, and prompt specification * Change notification capabilities == Starters @@ -32,7 +32,7 @@ Full MCP Server features support with `STDIO` server transport. The starter activates the `McpServerAutoConfiguration` auto-configuration responsible for: * Configuring the basic server components -* Handling tool, resource, and prompt registrations +* Handling tool, resource, and prompt specificaitons * Managing server capabilities and change notifications * Providing both sync and async server implementations @@ -50,7 +50,7 @@ Full MCP Server features support with `SSE` (Server-Sent Events) server transpor The starter activates the `McpWebMvcServerAutoConfiguration` and `McpServerAutoConfiguration` auto-configurations to provide: -* HTTP-based transport using Spring MVC (`WebMvcSseServerTransport`) +* HTTP-based transport using Spring MVC (`WebMvcSseServerTransportProvider`) * Automatically configured SSE endpoints * Optional `STDIO` transport (enabled by setting `spring.ai.mcp.server.stdio=true`) * Included `spring-boot-starter-web` and `mcp-spring-webmvc` dependencies @@ -69,7 +69,7 @@ Full MCP Server features support with `SSE` (Server-Sent Events) server transpor The starter activates the `McpWebFluxServerAutoConfiguration` and `McpServerAutoConfiguration` auto-configurations to provide: -* Reactive transport using Spring WebFlux (`WebFluxSseServerTransport`) +* Reactive transport using Spring WebFlux (`WebFluxSseServerTransportProvider`) * Automatically configured reactive SSE endpoints * Optional `STDIO` transport (enabled by setting `spring.ai.mcp.server.stdio=true`) * Included `spring-boot-starter-webflux` and `mcp-spring-webflux` dependencies @@ -98,11 +98,11 @@ All properties are prefixed with `spring.ai.mcp.server`: * **Synchronous Server** - The default server type implemented using `McpSyncServer`. It is designed for straightforward request-response patterns in your applications. To enable this server type, set `spring.ai.mcp.server.type=SYNC` in your configuration. -When activated, it automatically handles the configuration of synchronous tool registrations. +When activated, it automatically handles the configuration of synchronous tool specificaitons. * **Asynchronous Server** - The asynchronous server implementation uses `McpAsyncServer` and is optimized for non-blocking operations. To enable this server type, configure your application with `spring.ai.mcp.server.type=ASYNC`. -This server type automatically sets up asynchronous tool registrations with built-in Project Reactor support. +This server type automatically sets up asynchronous tool specificaitons with built-in Project Reactor support. == Transport Options @@ -115,14 +115,14 @@ The MCP Server supports three transport mechanisms, each with its dedicated star == Features and Capabilities The MCP Server Boot Starter allows servers to expose tools, resources, and prompts to clients. -It automatically converts custom capability handlers registered as Spring beans to sync/async registrations based on server type: +It automatically converts custom capability handlers registered as Spring beans to sync/async specificaitons based on server type: === link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/[Tools] Allows servers to expose tools that can be invoked by language models. The MCP Server Boot Starter provides: * Change notification support -* Tools are automatically converted to sync/async registrations based on server type -* Automatic tool registration through Spring beans: +* Tools are automatically converted to sync/async specificaitons based on server type +* Automatic tool specificaiton through Spring beans: [source,java] ---- @@ -138,8 +138,8 @@ or using the low-level API: [source,java] ---- @Bean -public List myTools(...) { - List tools = ... +public List myTools(...) { + List tools = ... return tools; } ---- @@ -148,18 +148,18 @@ public List myTools(...) { Provides a standardized way for servers to expose resources to clients. -* Static and dynamic resource registration +* Static and dynamic resource specificaitons * Optional change notifications * Support for resource templates -* Automatic conversion between sync/async resource registrations -* Automatic resource registration through Spring beans: +* Automatic conversion between sync/async resource specifications +* Automatic resource specificaiton through Spring beans: [source,java] ---- @Bean -public List myResources(...) { +public List myResources(...) { var systemInfoResource = new McpSchema.Resource(...); - var resourceRegistration = new McpServerFeatures.SyncResourceRegistration(systemInfoResource, request -> { + var resourceSpecificaiton = new McpServerFeatures.SyncResourceSpecificaiton(systemInfoResource, (exchange, request) -> { try { var systemInfo = Map.of(...); String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); @@ -171,7 +171,7 @@ public List myResources(...) { } }); - return List.of(resourceRegistration); + return List.of(resourceSpecificaiton); } ---- @@ -181,24 +181,24 @@ Provides a standardized way for servers to expose prompt templates to clients. * Change notification support * Template versioning -* Automatic conversion between sync/async prompt registrations -* Automatic prompt registration through Spring beans: +* Automatic conversion between sync/async prompt specificaitons +* Automatic prompt specification through Spring beans: [source,java] ---- @Bean -public List myPrompts() { +public List myPrompts() { var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt", List.of(new McpSchema.PromptArgument("name", "The name to greet", true))); - var promptRegistration = new McpServerFeatures.SyncPromptRegistration(prompt, getPromptRequest -> { + var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, (exchange, getPromptRequest) -> { String nameArgument = (String) getPromptRequest.arguments().get("name"); if (nameArgument == null) { nameArgument = "friend"; } var userMessage = new PromptMessage(Role.USER, new TextContent("Hello " + nameArgument + "! How can I assist you today?")); return new GetPromptResult("A personalized greeting message", List.of(userMessage)); }); - return List.of(promptRegistration); + return List.of(promptSpecification); } ---- @@ -213,8 +213,8 @@ When roots change, clients that support `listChanged` send a Root Change notific [source,java] ---- @Bean -public Consumer> rootsChangeConsumer() { - return roots -> { +public BiConsumer> rootsChangeHandler() { + return (exchange, roots) -> { logger.info("Registering root resources: {}", roots); }; } From b4b3b06064d20818fe6c0bf60c6979e01e92c708 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 19 Mar 2025 09:43:08 +0100 Subject: [PATCH 2/7] Address review comments Signed-off-by: Christian Tzolov --- .../McpBackwardCompatibility.java | 9 +++---- .../McpServerAutoConfiguration.java | 14 +++++----- ...ot.autoconfigure.AutoConfiguration.imports | 5 +++- .../springframework/ai/mcp/McpToolUtils.java | 10 +++---- .../api/mcp/mcp-server-boot-starter-docs.adoc | 26 +++++++++---------- 5 files changed, 30 insertions(+), 34 deletions(-) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java index 7981172e5bf..74663fdc150 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java @@ -44,12 +44,9 @@ public List>> syncRootsCh return List.of(); } - return rootsChangeConsumers.stream().map(c -> new BiConsumer>() { - @Override - public void accept(McpSyncServerExchange exchange, List roots) { - c.accept(roots); - } - }).collect(Collectors.toList()); + return rootsChangeConsumers.stream() + .map(c -> (BiConsumer>) ((exchange, roots) -> c.accept(roots))) + .toList(); } @Bean diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java index ae034000928..64d80f420db 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java @@ -97,7 +97,7 @@ * *

* WebMvc transport support is provided separately by - * {@link McpWebFluxServerAutoConfiguration}. + * {@link McpWebMvcServerAutoConfiguration}. * * @author Christian Tzolov * @since 1.0.0 @@ -132,12 +132,12 @@ public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() { @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", matchIfMissing = true) public List syncTools(ObjectProvider> toolCalls, - List toolCallbacks2, McpServerProperties serverProperties) { + List toolCallbacksList, McpServerProperties serverProperties) { List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); - if (!CollectionUtils.isEmpty(toolCallbacks2)) { - tools.addAll(toolCallbacks2); + if (!CollectionUtils.isEmpty(toolCallbacksList)) { + tools.addAll(toolCallbacksList); } return this.toSyncToolSpecifications(tools, serverProperties); @@ -219,11 +219,11 @@ public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider, @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") public List asyncTools(ObjectProvider> toolCalls, - List toolCallbacks2, McpServerProperties serverProperties) { + List toolCallbackList, McpServerProperties serverProperties) { List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); - if (!CollectionUtils.isEmpty(toolCallbacks2)) { - tools.addAll(toolCallbacks2); + if (!CollectionUtils.isEmpty(toolCallbackList)) { + tools.addAll(toolCallbackList); } return this.toAsyncToolSpecification(tools, serverProperties); diff --git a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e3f8fb74c03..a3d4c56dce6 100644 --- a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -13,4 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration + +org.springframework.ai.autoconfigure.mcp.server.McpServerAutoConfiguration +org.springframework.ai.autoconfigure.mcp.server.McpWebMvcServerAutoConfiguration +org.springframework.ai.autoconfigure.mcp.server.McpWebFluxServerAutoConfiguration diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java index 4d5b7983417..0355c5550e6 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java @@ -50,7 +50,7 @@ *

* This helper class provides methods to: *

    - *
  • Convert Spring AI's {@link ToolCallback} instances to MCP tool registrations
  • + *
  • Convert Spring AI's {@link ToolCallback} instances to MCP tool specification
  • *
  • Generate JSON schemas for tool input validation
  • *
* @@ -106,7 +106,6 @@ public static List toSyncToolRegistratio * semantics. * @param toolCallbacks the list of tool callbacks to convert * @return a list of MCP synchronous tool specificaiton - * @see #toSyncToolRegistration(ToolCallback) */ public static List toSyncToolSpecification( List toolCallbacks) { @@ -142,7 +141,6 @@ public static List toSyncToolRegistratio * usage when working with individual callbacks. * @param toolCallbacks the tool callbacks to convert * @return a list of MCP synchronous tool specificaiton - * @see #toSyncToolRegistration(List) */ public static List toSyncToolSpecifications( ToolCallback... toolCallbacks) { @@ -312,11 +310,10 @@ public static List toAsyncToolRegistrat *

* This method processes multiple tool callbacks in bulk, converting each one to its * corresponding MCP tool registration while adding asynchronous execution - * capabilities. The resulting registrations will execute their tools on a bounded + * capabilities. The resulting specifications will execute their tools on a bounded * elastic scheduler. * @param toolCallbacks the list of tool callbacks to convert * @return a list of MCP asynchronous tool specifications - * @see #toAsyncToolRegistration(ToolCallback) */ public static List toAsyncToolSpecifications( List toolCallbacks) { @@ -325,7 +322,7 @@ public static List toAsyncToolSpecific /** * Convenience method to convert a variable number of tool callbacks to MCP - * asynchronous tool registrations. + * asynchronous tool specifications. *

* This is a varargs wrapper around {@link #toAsyncToolRegistration(List)} for easier * usage when working with individual callbacks. @@ -469,7 +466,6 @@ public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(To * @param mimeType the MIME type of the output content * @return an MCP asynchronous tool specificaiotn that wraps the tool callback * @see McpServerFeatures.AsyncToolSpecification - * @see Mono * @see Schedulers#boundedElastic() */ public static McpServerFeatures.AsyncToolSpecification toAsyncToolSpecification(ToolCallback toolCallback, diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc index 3d4bfae6967..eafc751a786 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc @@ -32,7 +32,7 @@ Full MCP Server features support with `STDIO` server transport. The starter activates the `McpServerAutoConfiguration` auto-configuration responsible for: * Configuring the basic server components -* Handling tool, resource, and prompt specificaitons +* Handling tool, resource, and prompt specifications * Managing server capabilities and change notifications * Providing both sync and async server implementations @@ -89,7 +89,7 @@ All properties are prefixed with `spring.ai.mcp.server`: |`resource-change-notification` |Enable resource change notifications |`true` |`prompt-change-notification` |Enable prompt change notifications |`true` |`tool-change-notification` |Enable tool change notifications |`true` -|`tool-response-mime-type` |(optinal) response MIME type per tool name. For example `spring.ai.mcp.server.tool-response-mime-type.generateImage=image/png` will assosiate the `image/png` mime type with the `generateImage()` tool name |`-` +|`tool-response-mime-type` |(optional) response MIME type per tool name. For example `spring.ai.mcp.server.tool-response-mime-type.generateImage=image/png` will associate the `image/png` mime type with the `generateImage()` tool name |`-` |`sse-message-endpoint` |SSE endpoint path for web transport |`/mcp/message` |=== @@ -98,11 +98,11 @@ All properties are prefixed with `spring.ai.mcp.server`: * **Synchronous Server** - The default server type implemented using `McpSyncServer`. It is designed for straightforward request-response patterns in your applications. To enable this server type, set `spring.ai.mcp.server.type=SYNC` in your configuration. -When activated, it automatically handles the configuration of synchronous tool specificaitons. +When activated, it automatically handles the configuration of synchronous tool specifications. * **Asynchronous Server** - The asynchronous server implementation uses `McpAsyncServer` and is optimized for non-blocking operations. To enable this server type, configure your application with `spring.ai.mcp.server.type=ASYNC`. -This server type automatically sets up asynchronous tool specificaitons with built-in Project Reactor support. +This server type automatically sets up asynchronous tool specifications with built-in Project Reactor support. == Transport Options @@ -115,14 +115,14 @@ The MCP Server supports three transport mechanisms, each with its dedicated star == Features and Capabilities The MCP Server Boot Starter allows servers to expose tools, resources, and prompts to clients. -It automatically converts custom capability handlers registered as Spring beans to sync/async specificaitons based on server type: +It automatically converts custom capability handlers registered as Spring beans to sync/async specifications based on server type: === link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/[Tools] Allows servers to expose tools that can be invoked by language models. The MCP Server Boot Starter provides: * Change notification support -* Tools are automatically converted to sync/async specificaitons based on server type -* Automatic tool specificaiton through Spring beans: +* Tools are automatically converted to sync/async specifications based on server type +* Automatic tool specification through Spring beans: [source,java] ---- @@ -148,18 +148,18 @@ public List myTools(...) { Provides a standardized way for servers to expose resources to clients. -* Static and dynamic resource specificaitons +* Static and dynamic resource specifications * Optional change notifications * Support for resource templates * Automatic conversion between sync/async resource specifications -* Automatic resource specificaiton through Spring beans: +* Automatic resource specification through Spring beans: [source,java] ---- @Bean -public List myResources(...) { +public List myResources(...) { var systemInfoResource = new McpSchema.Resource(...); - var resourceSpecificaiton = new McpServerFeatures.SyncResourceSpecificaiton(systemInfoResource, (exchange, request) -> { + var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, (exchange, request) -> { try { var systemInfo = Map.of(...); String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); @@ -171,7 +171,7 @@ public List myResources(...) { } }); - return List.of(resourceSpecificaiton); + return List.of(resourceSpecification); } ---- @@ -181,7 +181,7 @@ Provides a standardized way for servers to expose prompt templates to clients. * Change notification support * Template versioning -* Automatic conversion between sync/async prompt specificaitons +* Automatic conversion between sync/async prompt specifications * Automatic prompt specification through Spring beans: [source,java] From 953d4df3aa1640a691ae60e99a29b72b115c839e Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 20 Mar 2025 22:48:11 +0100 Subject: [PATCH 3/7] add mcp 0.8.0 breaking change note Signed-off-by: Christian Tzolov --- .../antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc index f31200ebf5c..5f038bf1679 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc @@ -8,6 +8,13 @@ The link:https://modelcontextprotocol.io/sdk/java[MCP Java SDK] provides a Java `**Spring AI MCP**` extends the MCP Java SDK with Spring Boot integration, providing both xref:api/mcp/mcp-client-boot-starter-docs.adoc[client] and xref:api/mcp/mcp-server-boot-starter-docs.adoc[server] starters. Bootstrap your AI applications with MCP support using link:https://start.spring.io[Spring Initializer]. +[NOTE] +==== +Breaking Changes in MCP Java SDK 0.8.0 ⚠️ + +MCP Java SDK version 0.8.0 introduces several breaking changes including a new session-based architecture. If you're upgrading from Java SDK 0.7.0, please refer to the https://github.com/modelcontextprotocol/java-sdk/blob/main/migration-0.8.0.md[Migration Guide] for detailed instructions. +==== + == MCP Java SDK Architecture TIP: This section provides an overview for the link:https://modelcontextprotocol.io/sdk/java[MCP Java SDK architecture]. From d7fc91166b7d5d2d3dc89e33d247bf7351b3b55f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sat, 22 Mar 2025 09:43:33 +0100 Subject: [PATCH 4/7] Update mcp version to 0.8.0 Signed-off-by: Christian Tzolov --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b790468f2cc..fb2443abe7b 100644 --- a/pom.xml +++ b/pom.xml @@ -291,7 +291,7 @@ 4.12.0 - 0.8.0-SNAPSHOT + 0.8.0 4.13.1 From c346aa230528ff7f7347c2ee99a744d797e6b57e Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 24 Mar 2025 11:49:04 +0100 Subject: [PATCH 5/7] refactor: Extract MCP tool callback configuration into separate auto-configuration Extracts the MCP tool callback functionality from McpClientAutoConfiguration into a new dedicated McpToolCallbackAutoConfiguration that is disabled by default. - Created new McpToolCallbackAutoConfiguration class that handles tool callback registration - Made tool callbacks opt-in by requiring explicit configuration with spring.ai.mcp.client.toolcallback.enabled=true - Removed deprecated tool callback methods from McpClientAutoConfiguration - Updated ClientMcpTransport references to McpClientTransport to align with MCP library changes - Added tests for the new auto-configuration and its conditions Signed-off-by: Christian Tzolov --- .../McpClientAutoConfiguration.java | 51 --------- .../McpToolCallbackAutoConfiguration.java | 87 +++++++++++++++ .../NamedClientMcpTransport.java | 4 +- .../McpClientAutoConfigurationIT.java | 10 +- ...McpToolCallbackAutoConfigurationTests.java | 88 +++++++++++++++ ...llbackAutoconfiguraitonConditionTests.java | 102 ++++++++++++++++++ .../api/mcp/mcp-client-boot-starter-docs.adoc | 21 +++- 7 files changed, 302 insertions(+), 61 deletions(-) create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java index ba91930e523..c1870679a4e 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java @@ -31,8 +31,6 @@ import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.mcp.customizer.McpAsyncClientCustomizer; import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer; -import org.springframework.ai.tool.ToolCallback; -import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -179,36 +177,6 @@ public List mcpSyncClients(McpSyncClientConfigurer mcpSyncClientC return mcpSyncClients; } - /** - * Creates tool callbacks for all configured MCP clients. - * - *

- * These callbacks enable integration with Spring AI's tool execution framework, - * allowing MCP tools to be used as part of AI interactions. - * @param mcpClientsProvider provider of MCP sync clients - * @return list of tool callbacks for MCP integration - */ - @Bean - @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", - matchIfMissing = true) - public ToolCallbackProvider toolCallbacks(ObjectProvider> mcpClientsProvider) { - List mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList(); - return new SyncMcpToolCallbackProvider(mcpClients); - } - - /** - * @deprecated replaced by {@link #toolCallbacks(ObjectProvider)} that returns a - * {@link ToolCallbackProvider} instead of a list of {@link ToolCallback} - */ - @Deprecated - @Bean - @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", - matchIfMissing = true) - public List toolCallbacksDeprecated(ObjectProvider> mcpClientsProvider) { - List mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList(); - return List.of(new SyncMcpToolCallbackProvider(mcpClients).getToolCallbacks()); - } - /** * Record class that implements {@link AutoCloseable} to ensure proper cleanup of MCP * clients. @@ -292,25 +260,6 @@ public List mcpAsyncClients(McpAsyncClientConfigurer mcpSyncClie return mcpSyncClients; } - /** - * @deprecated replaced by {@link #asyncToolCallbacks(ObjectProvider)} that returns a - * {@link ToolCallbackProvider} instead of a list of {@link ToolCallback} - */ - @Deprecated - @Bean - @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") - public List asyncToolCallbacksDeprecated(ObjectProvider> mcpClientsProvider) { - List mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList(); - return List.of(new AsyncMcpToolCallbackProvider(mcpClients).getToolCallbacks()); - } - - @Bean - @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") - public ToolCallbackProvider asyncToolCallbacks(ObjectProvider> mcpClientsProvider) { - List mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList(); - return new AsyncMcpToolCallbackProvider(mcpClients); - } - public record CloseableMcpAsyncClients(List clients) implements AutoCloseable { @Override public void close() { diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java new file mode 100644 index 00000000000..4554c4e0b5f --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mcp.client; + +import java.util.List; + +import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.client.McpSyncClient; + +import org.springframework.ai.autoconfigure.mcp.client.properties.McpClientCommonProperties; +import org.springframework.ai.mcp.AsyncMcpToolCallbackProvider; +import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; + +/** + */ +@AutoConfiguration(after = { McpClientAutoConfiguration.class }) +@EnableConfigurationProperties(McpClientCommonProperties.class) +@Conditional(McpToolCallbackAutoConfiguration.McpToolCallbackAutoconfiguraitonCondition.class) +public class McpToolCallbackAutoConfiguration { + + public static class McpToolCallbackAutoconfiguraitonCondition extends AllNestedConditions { + + public McpToolCallbackAutoconfiguraitonCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + static class McpAutoConfigEnabled { + + } + + @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX + ".toolcallback", name = "enabled", + havingValue = "true", matchIfMissing = false) + static class ToolCallbackProviderEnabled { + + } + + } + + /** + * Creates tool callbacks for all configured MCP clients. + * + *

+ * These callbacks enable integration with Spring AI's tool execution framework, + * allowing MCP tools to be used as part of AI interactions. + * @param syncMcpClients provider of MCP sync clients + * @return list of tool callbacks for MCP integration + */ + @Bean + @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public ToolCallbackProvider mcpToolCallbacks(ObjectProvider> syncMcpClients) { + List mcpClients = syncMcpClients.stream().flatMap(List::stream).toList(); + return new SyncMcpToolCallbackProvider(mcpClients); + } + + @Bean + @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public ToolCallbackProvider mcpAsyncToolCallbacks(ObjectProvider> mcpClientsProvider) { + List mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList(); + return new AsyncMcpToolCallbackProvider(mcpClients); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/NamedClientMcpTransport.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/NamedClientMcpTransport.java index e5c7cbc0a13..6d254e32651 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/NamedClientMcpTransport.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/NamedClientMcpTransport.java @@ -15,7 +15,7 @@ */ package org.springframework.ai.mcp.client.autoconfigure; -import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; /** * A named MCP client transport. Usually created by the transport auto-configurations, but @@ -26,6 +26,6 @@ * @author Christian Tzolov * @since 1.0.0 */ -public record NamedClientMcpTransport(String name, ClientMcpTransport transport) { +public record NamedClientMcpTransport(String name, McpClientTransport transport) { } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfigurationIT.java index 71f18858a24..8372ebdd340 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfigurationIT.java @@ -23,7 +23,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -44,8 +44,8 @@ @Disabled public class McpClientAutoConfigurationIT { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(McpClientAutoConfiguration.class)); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(McpToolCallbackAutoConfiguration.class, McpClientAutoConfiguration.class)); @Test void defaultConfiguration() { @@ -131,7 +131,7 @@ static class TestTransportConfiguration { @Bean List testTransports() { - return List.of(new NamedClientMcpTransport("test", Mockito.mock(ClientMcpTransport.class))); + return List.of(new NamedClientMcpTransport("test", Mockito.mock(McpClientTransport.class))); } } @@ -157,7 +157,7 @@ McpSyncClientCustomizer testCustomizer() { } - static class CustomClientTransport implements ClientMcpTransport { + static class CustomClientTransport implements McpClientTransport { @Override public void close() { diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java new file mode 100644 index 00000000000..ed5f2c617a5 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mcp.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +public class McpToolCallbackAutoConfigurationTests { + + private final ApplicationContextRunner applicationContext = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpToolCallbackAutoConfiguration.class)); + + @Test + void disabledByDeafault() { + + this.applicationContext.run((context) -> { + assertThat(context).doesNotHaveBean("mcpToolCallbacks"); + assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks"); + }); + + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.enabled=true", "spring.ai.mcp.client.type=SYNC") + .run((context) -> { + assertThat(context).doesNotHaveBean("mcpToolCallbacks"); + assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks"); + }); + + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.enabled=true", "spring.ai.mcp.client.type=ASYNC") + .run((context) -> { + assertThat(context).doesNotHaveBean("mcpToolCallbacks"); + assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks"); + }); + } + + @Test + void enabledMcpToolCallbackAutoconfiguration() { + + // sync + this.applicationContext.withPropertyValues("spring.ai.mcp.client.toolcallback.enabled=true").run((context) -> { + assertThat(context).hasBean("mcpToolCallbacks"); + assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks"); + }); + + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.enabled=true", "spring.ai.mcp.client.toolcallback.enabled=true", + "spring.ai.mcp.client.type=SYNC") + .run((context) -> { + assertThat(context).hasBean("mcpToolCallbacks"); + assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks"); + }); + + // Async + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.toolcallback.enabled=true", "spring.ai.mcp.client.type=ASYNC") + .run((context) -> { + assertThat(context).doesNotHaveBean("mcpToolCallbacks"); + assertThat(context).hasBean("mcpAsyncToolCallbacks"); + }); + + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.enabled=true", "spring.ai.mcp.client.toolcallback.enabled=true", + "spring.ai.mcp.client.type=ASYNC") + .run((context) -> { + assertThat(context).doesNotHaveBean("mcpToolCallbacks"); + assertThat(context).hasBean("mcpAsyncToolCallbacks"); + }); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java new file mode 100644 index 00000000000..8c2f2f99532 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.mcp.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.autoconfigure.mcp.client.McpToolCallbackAutoConfiguration.McpToolCallbackAutoconfiguraitonCondition; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link McpToolCallbackAutoconfiguraitonCondition}. + */ +public class McpToolCallbackAutoconfiguraitonConditionTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfiguration.class); + + @Test + void matchesWhenBothPropertiesAreEnabled() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=true", "spring.ai.mcp.client.toolcallback.enabled=true") + .run(context -> { + assertThat(context).hasBean("testBean"); + }); + } + + @Test + void doesNotMatchWhenMcpClientIsDisabled() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=false", "spring.ai.mcp.client.toolcallback.enabled=true") + .run(context -> { + assertThat(context).doesNotHaveBean("testBean"); + }); + } + + @Test + void doesNotMatchWhenToolCallbackIsDisabled() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=true", "spring.ai.mcp.client.toolcallback.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("testBean"); + }); + } + + @Test + void doesNotMatchWhenBothPropertiesAreDisabled() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=false", "spring.ai.mcp.client.toolcallback.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("testBean"); + }); + } + + @Test + void doesNotMatchWhenToolCallbackPropertyIsMissing() { + // McpClientEnabled is true by default if missing, but ToolCallbackEnabled is + // false by default if missing + this.contextRunner.withPropertyValues("spring.ai.mcp.client.enabled=true").run(context -> { + assertThat(context).doesNotHaveBean("testBean"); + }); + } + + @Test + void doesNotMatchWhenBothPropertiesAreMissing() { + // McpClientEnabled is true by default if missing, but ToolCallbackEnabled is + // false by default if missing + this.contextRunner.run(context -> { + assertThat(context).doesNotHaveBean("testBean"); + }); + } + + @Configuration + @Conditional(McpToolCallbackAutoconfiguraitonCondition.class) + static class TestConfiguration { + + @Bean + String testBean() { + return "testBean"; + } + + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc index 1a05a805b81..26f518d2eba 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc @@ -78,6 +78,10 @@ The common properties are prefixed with `spring.ai.mcp.client`: |`root-change-notification` |Enable/disable root change notifications for all clients |`true` + +|`toolcallback.enabled` +|Enable/disable the MCP tool callback integration with Spring AI's tool execution framework +|`false` |=== === Stdio Transport Properties @@ -302,7 +306,7 @@ The auto-configuration supports multiple transport types: === Integration with Spring AI -The starter automatically configures tool callbacks that integrate with Spring AI's tool execution framework, allowing MCP tools to be used as part of AI interactions. +The starter can configure tool callbacks that integrate with Spring AI's tool execution framework, allowing MCP tools to be used as part of AI interactions. This integration is opt-in and must be explicitly enabled with the `spring.ai.mcp.client.toolcallback.enabled=true` property. == Usage Example @@ -351,8 +355,7 @@ private List mcpSyncClients; // For sync client private List mcpAsyncClients; // For async client ---- -Additionally, the registered MCP Tools with all MCP clients are provided as a list of ToolCallback -through a ToolCallbackProvider instance: +When tool callbacks are enabled, the registered MCP Tools with all MCP clients are provided as a ToolCallbackProvider instance: [source,java] ---- @@ -361,6 +364,18 @@ private SyncMcpToolCallbackProvider toolCallbackProvider; ToolCallback[] toolCallbacks = toolCallbackProvider.getToolCallbacks(); ---- +Note that the tool callback functionality is disabled by default and must be explicitly enabled with: + +[source,yaml] +---- +spring: + ai: + mcp: + client: + toolcallback: + enabled: true +---- + == Example Applications - link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/web-search/brave-chatbot[Brave Wet Search Chatbot] - A chatbot that uses the Model Context Protocol to interact with a web search server. From 55a11f6f29ffdece2d6fc57bab13db43e559aac4 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 25 Mar 2025 11:00:44 +0100 Subject: [PATCH 6/7] rebase to main Signed-off-by: Christian Tzolov --- .../mcp/client/autoconfigure/McpClientAutoConfiguration.java | 2 -- .../autoconfigure/McpToolCallbackAutoConfiguration.java | 4 ++-- ...ingframework.boot.autoconfigure.AutoConfiguration.imports | 1 + .../autoconfigure/McpToolCallbackAutoConfigurationTests.java | 2 +- .../McpToolCallbackAutoconfiguraitonConditionTests.java | 4 ++-- .../mcp/server/autoconfigure/McpBackwardCompatibility.java | 3 +-- .../autoconfigure/McpWebFluxServerAutoConfiguration.java | 3 --- .../autoconfigure/McpWebMvcServerAutoConfiguration.java | 1 - ...ingframework.boot.autoconfigure.AutoConfiguration.imports | 3 +-- .../McpServerAutoConfigurationBackwardCompatibilityIT.java | 2 +- ...ingframework.boot.autoconfigure.AutoConfiguration.imports | 5 +---- 11 files changed, 10 insertions(+), 20 deletions(-) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java index c1870679a4e..77077d43279 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpClientAutoConfiguration.java @@ -27,8 +27,6 @@ import org.springframework.ai.mcp.client.autoconfigure.configurer.McpAsyncClientConfigurer; import org.springframework.ai.mcp.client.autoconfigure.configurer.McpSyncClientConfigurer; import org.springframework.ai.mcp.client.autoconfigure.properties.McpClientCommonProperties; -import org.springframework.ai.mcp.AsyncMcpToolCallbackProvider; -import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.mcp.customizer.McpAsyncClientCustomizer; import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer; import org.springframework.beans.factory.ObjectProvider; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java index 4554c4e0b5f..59a1f8a6126 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfiguration.java @@ -14,16 +14,16 @@ * limitations under the License. */ -package org.springframework.ai.autoconfigure.mcp.client; +package org.springframework.ai.mcp.client.autoconfigure; import java.util.List; import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.client.McpSyncClient; -import org.springframework.ai.autoconfigure.mcp.client.properties.McpClientCommonProperties; import org.springframework.ai.mcp.AsyncMcpToolCallbackProvider; import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; +import org.springframework.ai.mcp.client.autoconfigure.properties.McpClientCommonProperties; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 78815050afa..e740e0f7de3 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -17,5 +17,6 @@ org.springframework.ai.mcp.client.autoconfigure.StdioTransportAutoConfiguration org.springframework.ai.mcp.client.autoconfigure.SseWebFluxTransportAutoConfiguration org.springframework.ai.mcp.client.autoconfigure.SseHttpClientTransportAutoConfiguration org.springframework.ai.mcp.client.autoconfigure.McpClientAutoConfiguration +org.springframework.ai.mcp.client.autoconfigure.McpToolCallbackAutoConfiguration diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java index ed5f2c617a5..86202c87c5d 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoConfigurationTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.autoconfigure.mcp.client; +package org.springframework.ai.mcp.client.autoconfigure; import org.junit.jupiter.api.Test; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java index 8c2f2f99532..64ab9e99db2 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/McpToolCallbackAutoconfiguraitonConditionTests.java @@ -14,11 +14,11 @@ * limitations under the License. */ -package org.springframework.ai.autoconfigure.mcp.client; +package org.springframework.ai.mcp.client.autoconfigure; import org.junit.jupiter.api.Test; -import org.springframework.ai.autoconfigure.mcp.client.McpToolCallbackAutoConfiguration.McpToolCallbackAutoconfiguraitonCondition; +import org.springframework.ai.mcp.client.autoconfigure.McpToolCallbackAutoConfiguration.McpToolCallbackAutoconfiguraitonCondition; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java index 74663fdc150..3d4e59be6d8 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.autoconfigure.mcp.server; +package org.springframework.ai.mcp.server.autoconfigure; import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; -import java.util.stream.Collectors; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServerExchange; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java index 9fbaa185ee6..eadb2b7cd90 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java @@ -17,11 +17,8 @@ package org.springframework.ai.mcp.server.autoconfigure; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; -import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; -import io.modelcontextprotocol.spec.ServerMcpTransport; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java index f0c63892005..828ef836a47 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java @@ -18,7 +18,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index eec917912b7..d2faa1cbfe5 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # - org.springframework.ai.mcp.server.autoconfigure.McpServerAutoConfiguration -org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration org.springframework.ai.mcp.server.autoconfigure.McpWebFluxServerAutoConfiguration +org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java index 945a10b80d4..63fae65dd97 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.autoconfigure.mcp.server; +package org.springframework.ai.mcp.server.autoconfigure; import java.util.List; import java.util.function.Consumer; diff --git a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index a3d4c56dce6..e3f8fb74c03 100644 --- a/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -13,7 +13,4 @@ # See the License for the specific language governing permissions and # limitations under the License. # - -org.springframework.ai.autoconfigure.mcp.server.McpServerAutoConfiguration -org.springframework.ai.autoconfigure.mcp.server.McpWebMvcServerAutoConfiguration -org.springframework.ai.autoconfigure.mcp.server.McpWebFluxServerAutoConfiguration +org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration From dae5fef0dbd3718eab69eb7a156627e6a326db99 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 25 Mar 2025 14:31:41 +0100 Subject: [PATCH 7/7] refactor: standardize tool names to use underscores instead of hyphens - Change separator in McpToolUtils.prefixedToolName from hyphen to underscore - Add conversion of any remaining hyphens to underscores in formatted tool names - Update affected tests to reflect the new naming convention - Add comprehensive tests for McpToolUtils.prefixedToolName method - Add integration test for payment transaction tools with Vertex AI Gemini Signed-off-by: Christian Tzolov --- .../springframework/ai/mcp/McpToolUtils.java | 4 +- .../ai/mcp/SyncMcpToolCallbackTests.java | 2 +- .../ai/mcp/ToolUtilsTests.java | 47 ++++ ...rtexAiGeminiPaymentTransactionToolsIT.java | 238 ++++++++++++++++++ 4 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/tool/VertexAiGeminiPaymentTransactionToolsIT.java diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java index 0355c5550e6..8e9a682aecc 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java @@ -67,12 +67,14 @@ public static String prefixedToolName(String prefix, String toolName) { throw new IllegalArgumentException("Prefix or toolName cannot be null or empty"); } - String input = prefix + "-" + toolName; + String input = prefix + "_" + toolName; // Replace any character that isn't alphanumeric, underscore, or hyphen with // concatenation String formatted = input.replaceAll("[^a-zA-Z0-9_-]", ""); + formatted = formatted.replaceAll("-", "_"); + // If the string is longer than 64 characters, keep the last 64 characters if (formatted.length() > 64) { formatted = formatted.substring(formatted.length() - 64); diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java index 747ee6352e9..47fb55ef1de 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java @@ -56,7 +56,7 @@ void getToolDefinitionShouldReturnCorrectDefinition() { var toolDefinition = callback.getToolDefinition(); - assertThat(toolDefinition.name()).isEqualTo(clientInfo.name() + "-testTool"); + assertThat(toolDefinition.name()).isEqualTo(clientInfo.name() + "_testTool"); assertThat(toolDefinition.description()).isEqualTo("Test tool description"); } diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java index 75005a0740f..631274a1f03 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java @@ -34,6 +34,7 @@ import org.springframework.ai.tool.definition.ToolDefinition; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -41,6 +42,52 @@ class ToolUtilsTests { + @Test + void prefixedToolNameShouldConcatenateWithUnderscore() { + String result = McpToolUtils.prefixedToolName("prefix", "toolName"); + assertThat(result).isEqualTo("prefix_toolName"); + } + + @Test + void prefixedToolNameShouldReplaceSpecialCharacters() { + String result = McpToolUtils.prefixedToolName("pre.fix", "tool@Name"); + assertThat(result).isEqualTo("prefix_toolName"); + } + + @Test + void prefixedToolNameShouldReplaceHyphensWithUnderscores() { + String result = McpToolUtils.prefixedToolName("pre-fix", "tool-name"); + assertThat(result).isEqualTo("pre_fix_tool_name"); + } + + @Test + void prefixedToolNameShouldTruncateLongStrings() { + String longPrefix = "a".repeat(40); + String longToolName = "b".repeat(40); + String result = McpToolUtils.prefixedToolName(longPrefix, longToolName); + assertThat(result).hasSize(64); + assertThat(result).endsWith("_" + longToolName); + } + + @Test + void prefixedToolNameShouldThrowExceptionForNullOrEmptyInputs() { + assertThatThrownBy(() -> McpToolUtils.prefixedToolName(null, "toolName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Prefix or toolName cannot be null or empty"); + + assertThatThrownBy(() -> McpToolUtils.prefixedToolName("", "toolName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Prefix or toolName cannot be null or empty"); + + assertThatThrownBy(() -> McpToolUtils.prefixedToolName("prefix", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Prefix or toolName cannot be null or empty"); + + assertThatThrownBy(() -> McpToolUtils.prefixedToolName("prefix", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Prefix or toolName cannot be null or empty"); + } + @Test void constructorShouldBePrivate() throws Exception { Constructor constructor = McpToolUtils.class.getDeclaredConstructor(); diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/tool/VertexAiGeminiPaymentTransactionToolsIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/tool/VertexAiGeminiPaymentTransactionToolsIT.java new file mode 100644 index 00000000000..47a245e446a --- /dev/null +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/tool/VertexAiGeminiPaymentTransactionToolsIT.java @@ -0,0 +1,238 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.vertexai.gemini.tool; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.cloud.vertexai.Transport; +import com.google.cloud.vertexai.VertexAI; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.api.AdvisedRequest; +import org.springframework.ai.chat.client.advisor.api.AdvisedResponse; +import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor; +import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver; +import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver; +import org.springframework.ai.tool.resolution.StaticToolCallbackResolver; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.GenericApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Christian Tzolov + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*") +@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*") +public class VertexAiGeminiPaymentTransactionToolsIT { + + private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionToolsIT.class); + + private static final Map DATASET = Map.of(new Transaction("001"), new Status("pending"), + new Transaction("002"), new Status("approved"), new Transaction("003"), new Status("rejected")); + + @Autowired + ChatClient chatClient; + + @Test + public void paymentStatuses() { + // @formatter:off + String content = this.chatClient.prompt() + .advisors(new LoggingAdvisor()) + .tools(new MyTools()) + .user(""" + What is the status of my payment transactions 001, 002 and 003? + If requred invoke the function per transaction. + """).call().content(); + // @formatter:on + logger.info("" + content); + + assertThat(content).contains("001", "002", "003"); + assertThat(content).contains("pending", "approved", "rejected"); + } + + @RepeatedTest(5) + public void streamingPaymentStatuses() { + + Flux streamContent = this.chatClient.prompt() + .advisors(new LoggingAdvisor()) + .tools(new MyTools()) + .user(""" + What is the status of my payment transactions 001, 002 and 003? + If requred invoke the function per transaction. + """) + .stream() + .content(); + + String content = streamContent.collectList().block().stream().collect(Collectors.joining()); + + logger.info(content); + + assertThat(content).contains("001", "002", "003"); + assertThat(content).contains("pending", "approved", "rejected"); + + // Quota rate + try { + Thread.sleep(1000); + } + catch (InterruptedException e) { + } + } + + record TransactionStatusResponse(String id, String status) { + + } + + private static class LoggingAdvisor implements CallAroundAdvisor { + + private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class); + + @Override + public String getName() { + return this.getClass().getSimpleName(); + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { + var response = chain.nextAroundCall(before(advisedRequest)); + observeAfter(response); + return response; + } + + private AdvisedRequest before(AdvisedRequest request) { + logger.info("System text: \n" + request.systemText()); + logger.info("System params: " + request.systemParams()); + logger.info("User text: \n" + request.userText()); + logger.info("User params:" + request.userParams()); + logger.info("Function names: " + request.functionNames()); + + logger.info("Options: " + request.chatOptions().toString()); + + return request; + } + + private void observeAfter(AdvisedResponse advisedResponse) { + logger.info("Response: " + advisedResponse.response()); + } + + } + + record Transaction(String id) { + } + + record Status(String name) { + } + + record Transactions(List transactions) { + } + + record Statuses(List statuses) { + } + + public static class MyTools { + + @Tool(description = "Get the list statuses of a list of payment transactions") + public Statuses paymentStatuses(Transactions transactions) { + logger.info("Transactions: " + transactions); + return new Statuses(transactions.transactions().stream().map(t -> DATASET.get(t)).toList()); + } + + } + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + public ChatClient chatClient(VertexAiGeminiChatModel chatModel) { + return ChatClient.builder(chatModel).build(); + } + + @Bean + public VertexAI vertexAiApi() { + + String projectId = System.getenv("VERTEX_AI_GEMINI_PROJECT_ID"); + String location = System.getenv("VERTEX_AI_GEMINI_LOCATION"); + + return new VertexAI.Builder().setLocation(location) + .setProjectId(projectId) + .setTransport(Transport.REST) + // .setTransport(Transport.GRPC) + .build(); + } + + @Bean + public VertexAiGeminiChatModel vertexAiChatModel(VertexAI vertexAi, ToolCallingManager toolCallingManager) { + + return VertexAiGeminiChatModel.builder() + .vertexAI(vertexAi) + .toolCallingManager(toolCallingManager) + .defaultOptions(VertexAiGeminiChatOptions.builder() + .model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH) + .temperature(0.1) + .build()) + .build(); + } + + @Bean + ToolCallingManager toolCallingManager(GenericApplicationContext applicationContext, + List toolCallbacks, ObjectProvider observationRegistry) { + + var staticToolCallbackResolver = new StaticToolCallbackResolver(toolCallbacks); + var springBeanToolCallbackResolver = SpringBeanToolCallbackResolver.builder() + .applicationContext(applicationContext) + .build(); + + ToolCallbackResolver toolCallbackResolver = new DelegatingToolCallbackResolver( + List.of(staticToolCallbackResolver, springBeanToolCallbackResolver)); + + return ToolCallingManager.builder() + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .toolCallbackResolver(toolCallbackResolver) + .toolExecutionExceptionProcessor(new DefaultToolExecutionExceptionProcessor(false)) + .build(); + } + + } + +}