From 3409c1fd870c1d78e1f60c742cac0e1cad3fda46 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sun, 6 Apr 2025 21:26:56 +0200 Subject: [PATCH] feat(mcp): update WebFlux and WebMvc server transport providers with URL configuration - Update MCP SDK version to 0.9.0 - Add baseUrl and sseEndpoint properties to McpServerProperties - Update WebFlux and WebMvc server transport providers to use new URL configuration properties - Remove deprecated backward compatibility code and related tests - Remove deprecated methods from McpToolUtils - Update MCP SDK version to 0.9.0-SNAPSHOT - Add tool filtering capability to MCP Tool Callback Providers Introduces a BiPredicate-based filtering mechanism for both Sync and Async MCP Tool Callback Providers, allowing selective tool discovery based on custom criteria. This enables filtering tools by name, client, or any combination of properties. * Apply filter in getToolCallbacks() methods for both providers * Add tests for various filtering scenarios - Add utility method to retrieve MCP exchange from tool context * Add constant TOOL_CONTEXT_MCP_EXCHANGE_KEY to replace hardcoded exchange string * Implement getMcpExchange utility method to safely retrieve the MCP exchange object Signed-off-by: Christian Tzolov --- .../McpBackwardCompatibility.java | 112 ------ .../McpServerAutoConfiguration.java | 2 - .../autoconfigure/McpServerProperties.java | 26 ++ ...oConfigurationBackwardCompatibilityIT.java | 319 ------------------ .../McpServerAutoConfigurationIT.java | 10 +- .../ai/mcp/AsyncMcpToolCallbackProvider.java | 39 ++- .../springframework/ai/mcp/McpToolUtils.java | 237 +------------ .../ai/mcp/SyncMcpToolCallbackProvider.java | 40 ++- .../mcp/SyncMcpToolCallbackProviderTests.java | 139 ++++++++ .../ai/mcp/ToolUtilsDeprecatedTests.java | 178 ---------- pom.xml | 2 +- .../api/mcp/mcp-server-boot-starter-docs.adoc | 4 +- 12 files changed, 258 insertions(+), 850 deletions(-) delete mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java delete mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java delete 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 deleted file mode 100644 index 3d4e59be6..000000000 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpBackwardCompatibility.java +++ /dev/null @@ -1,112 +0,0 @@ -/* -* 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.server.autoconfigure; - -import java.util.List; -import java.util.function.BiConsumer; -import java.util.function.Consumer; - -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 -> (BiConsumer>) ((exchange, roots) -> c.accept(roots))) - .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 7838a7440..7c88552e6 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 @@ -53,7 +53,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean 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; @@ -110,7 +109,6 @@ import org.springframework.util.MimeType; @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 { diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java index 938a74aec..92b26007e 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java @@ -98,6 +98,14 @@ public class McpServerProperties { */ private boolean promptChangeNotification = true; + /** + */ + private String baseUrl = ""; + + /** + */ + private String sseEndpoint = "/sse"; + /** * The endpoint path for Server-Sent Events (SSE) when using web transports. *

@@ -196,6 +204,24 @@ public class McpServerProperties { this.promptChangeNotification = promptChangeNotification; } + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + Assert.notNull(baseUrl, "Base URL must not be null"); + this.baseUrl = baseUrl; + } + + public String getSseEndpoint() { + return this.sseEndpoint; + } + + public void setSseEndpoint(String sseEndpoint) { + Assert.hasText(sseEndpoint, "SSE endpoint must not be empty"); + this.sseEndpoint = sseEndpoint; + } + public String getSseMessageEndpoint() { return this.sseMessageEndpoint; } 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 deleted file mode 100644 index fa50f74d8..000000000 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationBackwardCompatibilityIT.java +++ /dev/null @@ -1,319 +0,0 @@ -/* - * 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.server.autoconfigure; - -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.Disabled; -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; - -@Disabled -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 88d9c3580..c5218f9a5 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 @@ -18,7 +18,6 @@ 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; @@ -33,7 +32,6 @@ 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; @@ -122,7 +120,7 @@ public class McpServerAutoConfigurationIT { 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); + assertThat(context).doesNotHaveBean(McpServerTransport.class); }); } @@ -277,12 +275,6 @@ public class McpServerAutoConfigurationIT { 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 diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java index 6b645952d..73c4c20b1 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java @@ -17,8 +17,10 @@ package org.springframework.ai.mcp; import java.util.ArrayList; import java.util.List; +import java.util.function.BiPredicate; import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.spec.McpSchema.Tool; import io.modelcontextprotocol.util.Assert; import reactor.core.publisher.Flux; @@ -73,6 +75,21 @@ public class AsyncMcpToolCallbackProvider implements ToolCallbackProvider { private final List mcpClients; + private final BiPredicate toolFilter; + + /** + * Creates a new {@code AsyncMcpToolCallbackProvider} instance with a list of MCP + * clients. + * @param mcpClients the list of MCP clients to use for discovering tools + * @param toolFilter a filter to apply to each discovered tool + */ + public AsyncMcpToolCallbackProvider(BiPredicate toolFilter, List mcpClients) { + Assert.notNull(mcpClients, "MCP clients must not be null"); + Assert.notNull(toolFilter, "Tool filter must not be null"); + this.mcpClients = mcpClients; + this.toolFilter = toolFilter; + } + /** * Creates a new {@code AsyncMcpToolCallbackProvider} instance with a list of MCP * clients. @@ -82,13 +99,26 @@ public class AsyncMcpToolCallbackProvider implements ToolCallbackProvider { * @throws IllegalArgumentException if mcpClients is null */ public AsyncMcpToolCallbackProvider(List mcpClients) { - Assert.notNull(mcpClients, "McpClients must not be null"); - this.mcpClients = mcpClients; + this((mcpClient, tool) -> true, mcpClients); } + /** + * Creates a new {@code AsyncMcpToolCallbackProvider} instance with one or more MCP + * clients. + * @param mcpClients the MCP clients to use for discovering tools + * @param toolFilter a filter to apply to each discovered tool + */ + public AsyncMcpToolCallbackProvider(BiPredicate toolFilter, McpAsyncClient... mcpClients) { + this(toolFilter, List.of(mcpClients)); + } + + /** + * Creates a new {@code AsyncMcpToolCallbackProvider} instance with one or more MCP + * clients. + * @param mcpClients the MCP clients to use for discovering tools + */ public AsyncMcpToolCallbackProvider(McpAsyncClient... mcpClients) { - Assert.notNull(mcpClients, "McpClients must not be null"); - this.mcpClients = List.of(mcpClients); + this(List.of(mcpClients)); } /** @@ -116,6 +146,7 @@ public class AsyncMcpToolCallbackProvider implements ToolCallbackProvider { ToolCallback[] toolCallbacks = mcpClient.listTools() .map(response -> response.tools() .stream() + .filter(tool -> toolFilter.test(mcpClient, tool)) .map(tool -> new AsyncMcpToolCallback(mcpClient, tool)) .toArray(ToolCallback[]::new)) .block(); 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 c3c58accb..93d007fa8 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 @@ -17,6 +17,7 @@ package org.springframework.ai.mcp; import java.util.List; import java.util.Map; +import java.util.Optional; import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -24,7 +25,6 @@ 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.McpServerFeatures.AsyncToolRegistration; import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; @@ -62,6 +62,11 @@ import org.springframework.util.MimeType; */ public final class McpToolUtils { + /** + * The name of tool context key used to store the MCP exchange object. + */ + public static final String TOOL_CONTEXT_MCP_EXCHANGE_KEY = "exchange"; + private McpToolUtils() { } @@ -87,23 +92,6 @@ public final class McpToolUtils { return formatted; } - /** - * Converts a list of Spring AI tool callbacks to MCP synchronous tool registrations. - *

- * 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 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. *

@@ -118,27 +106,6 @@ public final class McpToolUtils { return toolCallbacks.stream().map(McpToolUtils::toSyncToolSpecification).toList(); } - /** - * Convenience method to convert a variable number of tool callbacks to MCP - * synchronous tool registrations. - *

- * This is a varargs wrapper around {@link #toSyncToolRegistration(List)} for easier - * usage when working with individual callbacks. - * @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)); - } - /** * Convenience method to convert a variable number of tool callbacks to MCP * synchronous tool specification. @@ -153,33 +120,6 @@ public final class McpToolUtils { 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. - * - *

- * The conversion process: - *

    - *
  • Creates an MCP Tool with the function's name and input schema
  • - *
  • Wraps the function's execution in a SyncToolRegistration 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 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 ToolCallback to an MCP SyncToolSpecification. This enables * Spring AI functions to be exposed as MCP tools that can be discovered and invoked @@ -205,65 +145,6 @@ public final class McpToolUtils { 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. - * - *

- * The conversion process: - *

    - *
  • Creates an MCP Tool with the function's name and input schema
  • - *
  • Wraps the function's execution in a SyncToolRegistration 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 - * @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) { - - var tool = new McpSchema.Tool(toolCallback.getToolDefinition().name(), - toolCallback.getToolDefinition().description(), toolCallback.getToolDefinition().inputSchema()); - - return new McpServerFeatures.SyncToolRegistration(tool, request -> { - try { - String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request)); - String imgData = callResult; - if (mimeType != null && "image".equals(mimeType.getType())) { - String imgType = mimeType.toString(); - if (callResult.startsWith("{") && callResult.endsWith("}")) { - // This is most likely a JSON structure: - // let's try to parse it as a base64 wrapper. - var b64Struct = JsonParser.fromJson(callResult, Base64Wrapper.class); - if (b64Struct.mimeType() != null && b64Struct.data() != null - && b64Struct.mimeType.getType().equals("image")) { - // Get the base64 encoded image as is. - imgType = b64Struct.mimeType().toString(); - imgData = b64Struct.data(); - } - } - return new McpSchema.CallToolResult( - List.of(new McpSchema.ImageContent(List.of(Role.ASSISTANT), null, imgData, imgType)), - 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 Spring AI ToolCallback to an MCP SyncToolSpecification. This enables * Spring AI functions to be exposed as MCP tools that can be discovered and invoked @@ -292,7 +173,7 @@ public final class McpToolUtils { return new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> { try { String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request), - new ToolContext(Map.of("exchange", exchange))); + new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, 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())), @@ -307,21 +188,16 @@ public final class McpToolUtils { } /** - * Converts a list of Spring AI tool callbacks to MCP asynchronous tool registrations. - *

- * 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 registrations - * @see #toAsyncToolRegistration(ToolCallback) - * @deprecated Use {@link #toAsyncToolSpecification(List)} instead. + * Retrieves the MCP exchange object from the provided tool context if it exists. + * @param toolContext the tool context from which to retrieve the MCP exchange + * @return the MCP exchange object, or null if not present in the context */ - @Deprecated - public static List toAsyncToolRegistration( - List toolCallbacks) { - return toolCallbacks.stream().map(McpToolUtils::toAsyncToolRegistration).toList(); + public static Optional getMcpExchange(ToolContext toolContext) { + if (toolContext != null && toolContext.getContext().containsKey(TOOL_CONTEXT_MCP_EXCHANGE_KEY)) { + return Optional + .ofNullable((McpSyncServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY)); + } + return Optional.empty(); } /** @@ -339,20 +215,6 @@ public final class McpToolUtils { return toolCallbacks.stream().map(McpToolUtils::toAsyncToolSpecification).toList(); } - /** - * Convenience method to convert a variable number of tool callbacks to MCP - * asynchronous tool specifications. - *

- * This is a varargs wrapper around {@link #toAsyncToolRegistration(List)} for easier - * usage when working with individual callbacks. - * @param toolCallbacks the tool callbacks to convert - * @return a list of MCP asynchronous tool registrations - * @see #toAsyncToolRegistration(List) - */ - public static List toAsyncToolRegistration(ToolCallback... toolCallbacks) { - return toAsyncToolRegistration(List.of(toolCallbacks)); - } - /** * Convenience method to convert a variable number of tool callbacks to MCP * asynchronous tool specificaiton. @@ -368,36 +230,6 @@ public final class McpToolUtils { return toAsyncToolSpecifications(List.of(toolCallbacks)); } - /** - * 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() - * @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. *

@@ -426,43 +258,6 @@ public final class McpToolUtils { return toAsyncToolSpecification(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 - * @param mimeType the MIME type of the output content - * @return an MCP asynchronous tool registration that wraps the tool callback - * @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) { - - McpServerFeatures.SyncToolRegistration syncToolRegistration = toSyncToolRegistration(toolCallback, mimeType); - - return new AsyncToolRegistration(syncToolRegistration.tool(), - map -> Mono.fromCallable(() -> syncToolRegistration.call().apply(map)) - .subscribeOn(Schedulers.boundedElastic())); - } - /** * Converts a Spring AI tool callback to an MCP asynchronous tool specification. *

diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java index 945dc4c1f..9e11b9dfc 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java @@ -17,12 +17,15 @@ package org.springframework.ai.mcp; import java.util.ArrayList; import java.util.List; +import java.util.function.BiPredicate; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema.Tool; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.tool.util.ToolUtils; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; /** @@ -69,17 +72,47 @@ public class SyncMcpToolCallbackProvider implements ToolCallbackProvider { private final List mcpClients; + private final BiPredicate toolFilter; + + /** + * Creates a new {@code SyncMcpToolCallbackProvider} instance with a list of MCP + * clients. + * @param mcpClients the list of MCP clients to use for discovering tools + * @param toolFilter a filter to apply to each discovered tool + */ + public SyncMcpToolCallbackProvider(BiPredicate toolFilter, List mcpClients) { + Assert.notNull(mcpClients, "MCP clients must not be null"); + Assert.notNull(toolFilter, "Tool filter must not be null"); + this.mcpClients = mcpClients; + this.toolFilter = toolFilter; + } + /** * Creates a new {@code SyncMcpToolCallbackProvider} instance with a list of MCP * clients. * @param mcpClients the list of MCP clients to use for discovering tools */ public SyncMcpToolCallbackProvider(List mcpClients) { - this.mcpClients = mcpClients; + this((mcpClient, tool) -> true, mcpClients); } + /** + * Creates a new {@code SyncMcpToolCallbackProvider} instance with one or more MCP + * clients. + * @param mcpClients the MCP clients to use for discovering tools + * @param toolFilter a filter to apply to each discovered tool + */ + public SyncMcpToolCallbackProvider(BiPredicate toolFilter, McpSyncClient... mcpClients) { + this(toolFilter, List.of(mcpClients)); + } + + /** + * Creates a new {@code SyncMcpToolCallbackProvider} instance with one or more MCP + * clients. + * @param mcpClients the MCP clients to use for discovering tools + */ public SyncMcpToolCallbackProvider(McpSyncClient... mcpClients) { - this.mcpClients = List.of(mcpClients); + this(List.of(mcpClients)); } /** @@ -99,10 +132,11 @@ public class SyncMcpToolCallbackProvider implements ToolCallbackProvider { var toolCallbacks = new ArrayList<>(); - mcpClients.stream().forEach(mcpClient -> { + this.mcpClients.stream().forEach(mcpClient -> { toolCallbacks.addAll(mcpClient.listTools() .tools() .stream() + .filter(tool -> toolFilter.test(mcpClient, tool)) .map(tool -> new SyncMcpToolCallback(mcpClient, tool)) .toList()); }); diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java index 4b654cf4e..be5cc9859 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.List; +import java.util.function.BiPredicate; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -129,4 +130,142 @@ class SyncMcpToolCallbackProviderTests { assertThat(callbacks).hasSize(2); } + @Test + void toolFilterShouldAcceptAllToolsByDefault() { + var clientInfo = new Implementation("testClient", "1.0.0"); + when(mcpClient.getClientInfo()).thenReturn(clientInfo); + + Tool tool1 = mock(Tool.class); + when(tool1.name()).thenReturn("tool1"); + + Tool tool2 = mock(Tool.class); + when(tool2.name()).thenReturn("tool2"); + + ListToolsResult listToolsResult = mock(ListToolsResult.class); + when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2)); + when(mcpClient.listTools()).thenReturn(listToolsResult); + + // Using the constructor without explicit filter (should use default filter that + // accepts all) + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(mcpClient); + + var callbacks = provider.getToolCallbacks(); + + assertThat(callbacks).hasSize(2); + } + + @Test + void toolFilterShouldRejectAllToolsWhenConfigured() { + + Tool tool1 = mock(Tool.class); + Tool tool2 = mock(Tool.class); + + ListToolsResult listToolsResult = mock(ListToolsResult.class); + when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2)); + when(mcpClient.listTools()).thenReturn(listToolsResult); + + // Create a filter that rejects all tools + BiPredicate rejectAllFilter = (client, tool) -> false; + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(rejectAllFilter, mcpClient); + + var callbacks = provider.getToolCallbacks(); + + assertThat(callbacks).isEmpty(); + } + + @Test + void toolFilterShouldFilterToolsByNameWhenConfigured() { + var clientInfo = new Implementation("testClient", "1.0.0"); + when(mcpClient.getClientInfo()).thenReturn(clientInfo); + + Tool tool1 = mock(Tool.class); + when(tool1.name()).thenReturn("tool1"); + + Tool tool2 = mock(Tool.class); + when(tool2.name()).thenReturn("tool2"); + + Tool tool3 = mock(Tool.class); + when(tool3.name()).thenReturn("tool3"); + + ListToolsResult listToolsResult = mock(ListToolsResult.class); + when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2, tool3)); + when(mcpClient.listTools()).thenReturn(listToolsResult); + + // Create a filter that only accepts tools with names containing "2" or "3" + BiPredicate nameFilter = (client, tool) -> tool.name().contains("2") + || tool.name().contains("3"); + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(nameFilter, mcpClient); + + var callbacks = provider.getToolCallbacks(); + + assertThat(callbacks).hasSize(2); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient_tool2"); + assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("testClient_tool3"); + } + + @Test + void toolFilterShouldFilterToolsByClientWhenConfigured() { + Tool tool1 = mock(Tool.class); + when(tool1.name()).thenReturn("tool1"); + + Tool tool2 = mock(Tool.class); + + McpSyncClient mcpClient1 = mock(McpSyncClient.class); + ListToolsResult listToolsResult1 = mock(ListToolsResult.class); + when(listToolsResult1.tools()).thenReturn(List.of(tool1)); + when(mcpClient1.listTools()).thenReturn(listToolsResult1); + + var clientInfo1 = new Implementation("testClient1", "1.0.0"); + when(mcpClient1.getClientInfo()).thenReturn(clientInfo1); + + McpSyncClient mcpClient2 = mock(McpSyncClient.class); + ListToolsResult listToolsResult2 = mock(ListToolsResult.class); + when(listToolsResult2.tools()).thenReturn(List.of(tool2)); + when(mcpClient2.listTools()).thenReturn(listToolsResult2); + + var clientInfo2 = new Implementation("testClient2", "1.0.0"); + when(mcpClient2.getClientInfo()).thenReturn(clientInfo2); + + // Create a filter that only accepts tools from client1 + BiPredicate clientFilter = (client, + tool) -> client.getClientInfo().name().equals("testClient1"); + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(clientFilter, mcpClient1, mcpClient2); + + var callbacks = provider.getToolCallbacks(); + + assertThat(callbacks).hasSize(1); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient1_tool1"); + } + + @Test + void toolFilterShouldCombineClientAndToolCriteriaWhenConfigured() { + Tool tool1 = mock(Tool.class); + when(tool1.name()).thenReturn("weather"); + + Tool tool2 = mock(Tool.class); + when(tool2.name()).thenReturn("calculator"); + + McpSyncClient weatherClient = mock(McpSyncClient.class); + ListToolsResult weatherResult = mock(ListToolsResult.class); + when(weatherResult.tools()).thenReturn(List.of(tool1, tool2)); + when(weatherClient.listTools()).thenReturn(weatherResult); + + var weatherClientInfo = new Implementation("weather-service", "1.0.0"); + when(weatherClient.getClientInfo()).thenReturn(weatherClientInfo); + + // Create a filter that only accepts weather tools from the weather service + BiPredicate complexFilter = (client, + tool) -> client.getClientInfo().name().equals("weather-service") && tool.name().equals("weather"); + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(complexFilter, weatherClient); + + var callbacks = provider.getToolCallbacks(); + + assertThat(callbacks).hasSize(1); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("weather_service_weather"); + } + } 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 deleted file mode 100644 index 2a7c8e067..000000000 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsDeprecatedTests.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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/pom.xml b/pom.xml index 6a1a5e5de..b3e75ae8d 100644 --- a/pom.xml +++ b/pom.xml @@ -306,7 +306,7 @@ 4.12.0 - 0.8.1 + 0.9.0 4.13.1 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 4bdeb7425..14f705ced 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 @@ -96,7 +96,9 @@ All properties are prefixed with `spring.ai.mcp.server`: |`prompt-change-notification` |Enable prompt change notifications |`true` |`tool-change-notification` |Enable tool change notifications |`true` |`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` +|`sse-message-endpoint` | Custom SSE Message endpoint path for web transport to be used by the client to send messages|`/mcp/message` +|`sse-endpoint` |Custom SSE endpoint path for web transport |`/sse` +|`base-url` | Optional URL prefix. For example `base-url=/api/v1` means that the client should access the sse endpont at `/api/v1` + `sse-endpoint` and the message endpoint is `/api/v1` + `sse-message-endpoint` | - |=== == Sync/Async Server Types