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..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,12 +27,8 @@ 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.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 +175,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 +258,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..59a1f8a6126 --- /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.mcp.client.autoconfigure; + +import java.util.List; + +import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.client.McpSyncClient; + +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; +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/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/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..86202c87c5d --- /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.mcp.client.autoconfigure; + +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..64ab9e99db2 --- /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.mcp.client.autoconfigure; + +import org.junit.jupiter.api.Test; + +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; +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/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..3d4e59be6d8 --- /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,112 @@ +/* +* 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 ae8e86ef5a0..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 @@ -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; @@ -100,11 +103,13 @@ * @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 toolCallbacksList, McpServerProperties serverProperties) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolCallbacksList)) { + tools.addAll(toolCallbacksList); + } - 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 toolCallbackList, McpServerProperties serverProperties) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + if (!CollectionUtils.isEmpty(toolCallbackList)) { + tools.addAll(toolCallbackList); + } - 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..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,8 +17,8 @@ package org.springframework.ai.mcp.server.autoconfigure; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; -import io.modelcontextprotocol.spec.ServerMcpTransport; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -34,8 +34,8 @@ * server, providing reactive Server-Sent Events (SSE) communication through Spring * WebFlux. It is activated when: *

* - * 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: + *

+ * + * 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: + *

+ * @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,15 +299,32 @@ 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 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 + */ + 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. + * asynchronous tool specifications. *

* This is a varargs wrapper around {@link #toAsyncToolRegistration(List)} for easier * usage when working with individual callbacks. @@ -205,6 +336,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 +374,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: + *

+ *

+ * The resulting async registration will: + *

+ * @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 +433,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 +446,41 @@ 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: + *

+ *

+ * The resulting async specificaiton will: + *

+ * @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 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/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/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..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 @@ -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,12 +34,60 @@ 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; import static org.mockito.Mockito.when; 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(); @@ -47,103 +97,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 +195,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 +207,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/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(); + } + + } + +} 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 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. 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-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]. 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..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 @@ -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 specifications * 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 @@ -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 registrations. +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 registrations 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 registrations 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 registrations based on server type -* Automatic tool registration through Spring beans: +* Tools are automatically converted to sync/async specifications based on server type +* Automatic tool specification 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 specifications * 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 specification 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 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(resourceRegistration); + return List.of(resourceSpecification); } ---- @@ -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 specifications +* 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); }; }