feat(mcp): Refactor MCP server API to use Specification pattern

- Rename Registration classes to Specification (SyncToolRegistration → SyncToolSpecification)
- Update transport classes to use Provider suffix (WebFluxSseServerTransport → WebFluxSseServerTransportProvider)
- Add exchange parameter to handler methods for better context passing
- Introduce McpBackwardCompatibility class to maintain backward compatibility
- Update MCP Server documentation to reflect new API patterns
- Add tests for backward compatibility
- Update mcp version to 0.8.0
- Add mcp 0.8.0 breaking change note-

The changes align with the MCP specification evolution while maintaining backward compatibility through deprecated APIs.

refactor: Extract MCP tool callback configuration into separate auto-configuration

Extracts the MCP tool callback functionality from McpClientAutoConfiguration into a
new dedicated McpToolCallbackAutoConfiguration that is disabled by default.

- Created new McpToolCallbackAutoConfiguration class that handles tool callback registration
- Made tool callbacks opt-in by requiring explicit configuration with spring.ai.mcp.client.toolcallback.enabled=true
- Removed deprecated tool callback methods from McpClientAutoConfiguration
- Updated ClientMcpTransport references to McpClientTransport to align with MCP library changes
- Added tests for the new auto-configuration and its conditions

refactor: standardize tool names to use underscores instead of hyphens

- Change separator in McpToolUtils.prefixedToolName from hyphen to underscore
- Add conversion of any remaining hyphens to underscores in formatted tool names
- Update affected tests to reflect the new naming convention
- Add comprehensive tests for McpToolUtils.prefixedToolName method
- Add integration test for payment transaction tools with Vertex AI Gemini

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-03-18 12:20:54 +01:00
committed by Mark Pollack
parent 3ca8d703df
commit fc955c74eb
24 changed files with 1653 additions and 284 deletions

View File

@@ -27,12 +27,8 @@ import io.modelcontextprotocol.spec.McpSchema;
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 class McpClientAutoConfiguration {
return mcpSyncClients;
}
/**
* Creates tool callbacks for all configured MCP clients.
*
* <p>
* 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<List<McpSyncClient>> mcpClientsProvider) {
List<McpSyncClient> 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<ToolCallback> toolCallbacksDeprecated(ObjectProvider<List<McpSyncClient>> mcpClientsProvider) {
List<McpSyncClient> 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 class McpClientAutoConfiguration {
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<ToolCallback> asyncToolCallbacksDeprecated(ObjectProvider<List<McpAsyncClient>> mcpClientsProvider) {
List<McpAsyncClient> 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<List<McpAsyncClient>> mcpClientsProvider) {
List<McpAsyncClient> mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList();
return new AsyncMcpToolCallbackProvider(mcpClients);
}
public record CloseableMcpAsyncClients(List<McpAsyncClient> clients) implements AutoCloseable {
@Override
public void close() {

View File

@@ -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.McpToolCallbackAutoconfigurationCondition.class)
public class McpToolCallbackAutoConfiguration {
public static class McpToolCallbackAutoconfigurationCondition extends AllNestedConditions {
public McpToolCallbackAutoconfigurationCondition() {
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.
*
* <p>
* 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<List<McpSyncClient>> syncMcpClients) {
List<McpSyncClient> 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<List<McpAsyncClient>> mcpClientsProvider) {
List<McpAsyncClient> mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList();
return new AsyncMcpToolCallbackProvider(mcpClients);
}
}

View File

@@ -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 @@ import io.modelcontextprotocol.spec.ClientMcpTransport;
* @author Christian Tzolov
* @since 1.0.0
*/
public record NamedClientMcpTransport(String name, ClientMcpTransport transport) {
public record NamedClientMcpTransport(String name, McpClientTransport transport) {
}

View File

@@ -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

View File

@@ -23,7 +23,7 @@ import java.util.function.Function;
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 @@ import static org.assertj.core.api.Assertions.assertThat;
@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 @@ public class McpClientAutoConfigurationIT {
@Bean
List<NamedClientMcpTransport> 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 @@ public class McpClientAutoConfigurationIT {
}
static class CustomClientTransport implements ClientMcpTransport {
static class CustomClientTransport implements McpClientTransport {
@Override
public void close() {

View File

@@ -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");
});
}
}

View File

@@ -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.McpToolCallbackAutoconfigurationCondition;
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 McpToolCallbackAutoconfigurationCondition}.
*/
public class McpToolCallbackAutoconfigurationConditionTests {
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(McpToolCallbackAutoconfigurationCondition.class)
static class TestConfiguration {
@Bean
String testBean() {
return "testBean";
}
}
}

View File

@@ -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<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> syncRootsChangeConsumerToHandler(
List<Consumer<List<McpSchema.Root>>> rootsChangeConsumers) {
if (CollectionUtils.isEmpty(rootsChangeConsumers)) {
return List.of();
}
return rootsChangeConsumers.stream()
.map(c -> (BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>) ((exchange, roots) -> c.accept(roots)))
.toList();
}
@Bean
public List<McpServerFeatures.SyncToolSpecification> syncToolsRegistrationToSpecificaiton(
ObjectProvider<List<McpServerFeatures.SyncToolRegistration>> toolRegistrations) {
return toolRegistrations.stream()
.flatMap(List::stream)
.map(McpServerFeatures.SyncToolRegistration::toSpecification)
.toList();
}
@Bean
public List<McpServerFeatures.SyncResourceSpecification> syncResourceRegistrationToSpecificaiton(
ObjectProvider<List<McpServerFeatures.SyncResourceRegistration>> resourceRegistrations) {
return resourceRegistrations.stream()
.flatMap(List::stream)
.map(McpServerFeatures.SyncResourceRegistration::toSpecification)
.toList();
}
@Bean
public List<McpServerFeatures.SyncPromptSpecification> syncPromptRegistrationToSpecificaiton(
ObjectProvider<List<McpServerFeatures.SyncPromptRegistration>> promptRegistrations) {
return promptRegistrations.stream()
.flatMap(List::stream)
.map(McpServerFeatures.SyncPromptRegistration::toSpecification)
.toList();
}
// Async
@Bean
public List<McpServerFeatures.AsyncToolSpecification> asyncToolsRegistrationToSpecificaiton(
ObjectProvider<List<McpServerFeatures.AsyncToolRegistration>> toolRegistrations) {
return toolRegistrations.stream()
.flatMap(List::stream)
.map(McpServerFeatures.AsyncToolRegistration::toSpecification)
.toList();
}
@Bean
public List<McpServerFeatures.AsyncResourceSpecification> asyncResourceRegistrationToSpecificaiton(
ObjectProvider<List<McpServerFeatures.AsyncResourceRegistration>> resourceRegistrations) {
return resourceRegistrations.stream()
.flatMap(List::stream)
.map(McpServerFeatures.AsyncResourceRegistration::toSpecification)
.toList();
}
@Bean
public List<McpServerFeatures.AsyncPromptSpecification> asyncPromptRegistrationToSpecificaiton(
ObjectProvider<List<McpServerFeatures.AsyncPromptRegistration>> promptRegistrations) {
return promptRegistrations.stream()
.flatMap(List::stream)
.map(McpServerFeatures.AsyncPromptRegistration::toSpecification)
.toList();
}
}

View File

@@ -18,25 +18,27 @@ package org.springframework.ai.mcp.server.autoconfigure;
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.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;
@@ -100,11 +103,13 @@ import org.springframework.util.MimeType;
* @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 class McpServerAutoConfiguration {
@Bean
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
matchIfMissing = true)
public List<McpServerFeatures.SyncToolRegistration> syncTools(ObjectProvider<List<ToolCallback>> toolCalls,
McpServerProperties serverProperties) {
List<ToolCallback> tools = toolCalls.stream().flatMap(List::stream).toList();
public List<McpServerFeatures.SyncToolSpecification> syncTools(ObjectProvider<List<ToolCallback>> toolCalls,
List<ToolCallback> toolCallbacksList, McpServerProperties serverProperties) {
return this.toSyncToolRegistration(tools, serverProperties);
List<ToolCallback> tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList());
if (!CollectionUtils.isEmpty(toolCallbacksList)) {
tools.addAll(toolCallbacksList);
}
return this.toSyncToolSpecifications(tools, serverProperties);
}
private List<McpServerFeatures.SyncToolRegistration> toSyncToolRegistration(List<ToolCallback> tools,
private List<McpServerFeatures.SyncToolSpecification> toSyncToolSpecifications(List<ToolCallback> 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<List<SyncToolRegistration>> tools, ObjectProvider<List<SyncResourceRegistration>> resources,
ObjectProvider<List<SyncPromptRegistration>> prompts,
ObjectProvider<Consumer<List<McpSchema.Root>>> rootsChangeConsumers,
ObjectProvider<List<SyncToolSpecification>> tools,
ObjectProvider<List<SyncResourceSpecification>> resources,
ObjectProvider<List<SyncPromptSpecification>> prompts,
ObjectProvider<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers,
List<ToolCallbackProvider> 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<SyncToolRegistration> toolRegistrations = new ArrayList<>(tools.stream().flatMap(List::stream).toList());
List<SyncToolSpecification> toolSpecifications = new ArrayList<>(tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
@@ -168,33 +179,35 @@ public class McpServerAutoConfiguration {
.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<SyncResourceRegistration> resourceRegistrations = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceRegistrations)) {
serverBuilder.resources(resourceRegistrations);
List<SyncResourceSpecification> 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<SyncPromptRegistration> promptRegistrations = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptRegistrations)) {
serverBuilder.prompts(promptRegistrations);
List<SyncPromptSpecification> 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 class McpServerAutoConfiguration {
@Bean
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC")
public List<McpServerFeatures.AsyncToolRegistration> asyncTools(ObjectProvider<List<ToolCallback>> toolCalls,
McpServerProperties serverProperties) {
var tools = toolCalls.stream().flatMap(List::stream).toList();
public List<McpServerFeatures.AsyncToolSpecification> asyncTools(ObjectProvider<List<ToolCallback>> toolCalls,
List<ToolCallback> toolCallbackList, McpServerProperties serverProperties) {
return this.toAsyncToolRegistration(tools, serverProperties);
List<ToolCallback> tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList());
if (!CollectionUtils.isEmpty(toolCallbackList)) {
tools.addAll(toolCallbackList);
}
return this.toAsyncToolSpecification(tools, serverProperties);
}
private List<McpServerFeatures.AsyncToolRegistration> toAsyncToolRegistration(List<ToolCallback> tools,
private List<McpServerFeatures.AsyncToolSpecification> toAsyncToolSpecification(List<ToolCallback> 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<List<AsyncToolRegistration>> tools,
ObjectProvider<List<AsyncResourceRegistration>> resources,
ObjectProvider<List<AsyncPromptRegistration>> prompts,
ObjectProvider<Consumer<List<McpSchema.Root>>> rootsChangeConsumer,
ObjectProvider<List<AsyncToolSpecification>> tools,
ObjectProvider<List<AsyncResourceSpecification>> resources,
ObjectProvider<List<AsyncPromptSpecification>> prompts,
ObjectProvider<BiConsumer<McpAsyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumer,
List<ToolCallbackProvider> 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<AsyncToolRegistration> toolRegistrations = new ArrayList<>(tools.stream().flatMap(List::stream).toList());
List<AsyncToolSpecification> toolSpecifications = new ArrayList<>(
tools.stream().flatMap(List::stream).toList());
List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream()
.map(pr -> List.of(pr.getToolCallbacks()))
.flatMap(List::stream)
@@ -246,37 +264,37 @@ public class McpServerAutoConfiguration {
.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<AsyncResourceRegistration> resourceRegistrations = resources.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(resourceRegistrations)) {
serverBuilder.resources(resourceRegistrations);
List<AsyncResourceSpecification> 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<AsyncPromptRegistration> promptRegistrations = prompts.stream().flatMap(List::stream).toList();
if (!CollectionUtils.isEmpty(promptRegistrations)) {
serverBuilder.prompts(promptRegistrations);
List<AsyncPromptSpecification> 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<List<McpSchema.Root>, Mono<Void>> asyncConsumer = roots -> {
consumer.accept(roots);
BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>> asyncConsumer = (exchange, roots) -> {
consumer.accept(exchange, roots);
return Mono.empty();
};
serverBuilder.rootsChangeConsumer(asyncConsumer);
serverBuilder.rootsChangeHandler(asyncConsumer);
logger.info("Registered roots change consumer");
});

View File

@@ -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 @@ import org.springframework.web.reactive.function.server.RouterFunction;
* server, providing reactive Server-Sent Events (SSE) communication through Spring
* WebFlux. It is activated when:
* <ul>
* <li>The WebFluxSseServerTransport class is on the classpath (from mcp-spring-webflux
* dependency)</li>
* <li>The WebFluxSseServerTransportProvider class is on the classpath (from
* mcp-spring-webflux dependency)</li>
* <li>Spring WebFlux's RouterFunction class is available (from
* spring-boot-starter-webflux)</li>
* <li>The {@code spring.ai.mcp.server.transport} property is set to {@code WEBFLUX}</li>
@@ -43,7 +43,8 @@ import org.springframework.web.reactive.function.server.RouterFunction;
* <p>
* The configuration provides:
* <ul>
* <li>A WebFluxSseServerTransport bean for handling reactive SSE communication</li>
* <li>A WebFluxSseServerTransportProvider bean for handling reactive SSE
* communication</li>
* <li>A RouterFunction bean that sets up the reactive SSE endpoint</li>
* </ul>
* <p>
@@ -61,25 +62,25 @@ import org.springframework.web.reactive.function.server.RouterFunction;
* @author Christian Tzolov
* @since 1.0.0
* @see McpServerProperties
* @see WebFluxSseServerTransport
* @see WebFluxSseServerTransportProvider
*/
@AutoConfiguration
@ConditionalOnClass({ WebFluxSseServerTransport.class })
@ConditionalOnMissingBean(ServerMcpTransport.class)
@ConditionalOnClass({ WebFluxSseServerTransportProvider.class })
@ConditionalOnMissingBean(McpServerTransportProvider.class)
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "stdio", havingValue = "false",
matchIfMissing = true)
public class McpWebFluxServerAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public WebFluxSseServerTransport webFluxTransport(McpServerProperties serverProperties) {
return new WebFluxSseServerTransport(new ObjectMapper(), serverProperties.getSseMessageEndpoint());
public WebFluxSseServerTransportProvider webFluxTransport(McpServerProperties serverProperties) {
return new WebFluxSseServerTransportProvider(new ObjectMapper(), serverProperties.getSseMessageEndpoint());
}
// Router function for SSE transport used by Spring WebFlux to start an HTTP server.
@Bean
public RouterFunction<?> webfluxMcpRouterFunction(WebFluxSseServerTransport webFluxTransport) {
return webFluxTransport.getRouterFunction();
public RouterFunction<?> webfluxMcpRouterFunction(WebFluxSseServerTransportProvider webFluxProvider) {
return webFluxProvider.getRouterFunction();
}
}

View File

@@ -17,8 +17,8 @@
package org.springframework.ai.mcp.server.autoconfigure;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransport;
import io.modelcontextprotocol.spec.ServerMcpTransport;
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
@@ -57,25 +57,25 @@ import org.springframework.web.servlet.function.ServerResponse;
* @author Christian Tzolov
* @since 1.0.0
* @see McpServerProperties
* @see WebMvcSseServerTransport
* @see WebMvcSseServerTransportProvider
*/
@AutoConfiguration
@ConditionalOnClass({ WebMvcSseServerTransport.class })
@ConditionalOnMissingBean(ServerMcpTransport.class)
@ConditionalOnClass({ WebMvcSseServerTransportProvider.class })
@ConditionalOnMissingBean(McpServerTransportProvider.class)
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "stdio", havingValue = "false",
matchIfMissing = true)
public class McpWebMvcServerAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public WebMvcSseServerTransport webMvcSseServerTransport(ObjectMapper objectMapper,
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectMapper objectMapper,
McpServerProperties serverProperties) {
return new WebMvcSseServerTransport(objectMapper, serverProperties.getSseMessageEndpoint());
return new WebMvcSseServerTransportProvider(objectMapper, serverProperties.getSseMessageEndpoint());
}
@Bean
public RouterFunction<ServerResponse> mvcMcpRouterFunction(WebMvcSseServerTransport transport) {
return transport.getRouterFunction();
public RouterFunction<ServerResponse> mvcMcpRouterFunction(WebMvcSseServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}

View File

@@ -13,8 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.springframework.ai.mcp.server.autoconfigure.McpServerAutoConfiguration
org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration
org.springframework.ai.mcp.server.autoconfigure.McpWebFluxServerAutoConfiguration
org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration

View File

@@ -0,0 +1,318 @@
/*
* Copyright 2025-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.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.Test;
import org.mockito.Mockito;
import reactor.core.publisher.Mono;
import org.springframework.ai.mcp.SyncMcpToolCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@Deprecated
public class McpServerAutoConfigurationBackwardCompatibilityIT {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(McpServerAutoConfiguration.class));
@Test
void defaultConfiguration() {
this.contextRunner.run(context -> {
assertThat(context).hasSingleBean(McpSyncServer.class);
assertThat(context).hasSingleBean(McpServerTransportProvider.class);
assertThat(context.getBean(McpServerTransportProvider.class))
.isInstanceOf(StdioServerTransportProvider.class);
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getName()).isEqualTo("mcp-server");
assertThat(properties.getVersion()).isEqualTo("1.0.0");
assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.SYNC);
assertThat(properties.isToolChangeNotification()).isTrue();
assertThat(properties.isResourceChangeNotification()).isTrue();
assertThat(properties.isPromptChangeNotification()).isTrue();
});
}
@Test
void asyncConfiguration() {
this.contextRunner
.withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.name=test-server",
"spring.ai.mcp.server.version=2.0.0")
.run(context -> {
assertThat(context).hasSingleBean(McpAsyncServer.class);
assertThat(context).doesNotHaveBean(McpSyncServer.class);
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getName()).isEqualTo("test-server");
assertThat(properties.getVersion()).isEqualTo("2.0.0");
assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.ASYNC);
});
}
@Test
void transportConfiguration() {
this.contextRunner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> {
assertThat(context).hasSingleBean(McpServerTransport.class);
assertThat(context.getBean(McpServerTransport.class)).isInstanceOf(CustomServerTransport.class);
});
}
@Test
void serverNotificationConfiguration() {
this.contextRunner
.withPropertyValues("spring.ai.mcp.server.tool-change-notification=false",
"spring.ai.mcp.server.resource-change-notification=false")
.run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.isToolChangeNotification()).isFalse();
assertThat(properties.isResourceChangeNotification()).isFalse();
});
}
// @Test
void invalidConfigurationThrowsException() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.version=invalid-version").run(context -> {
assertThat(context).hasFailed();
assertThat(context).getFailure()
.hasRootCauseInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid version format");
});
}
@Test
void disabledConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.enabled=false").run(context -> {
assertThat(context).doesNotHaveBean(McpSyncServer.class);
assertThat(context).doesNotHaveBean(McpAsyncServer.class);
assertThat(context).doesNotHaveBean(ServerMcpTransport.class);
});
}
@Test
void notificationConfiguration() {
this.contextRunner
.withPropertyValues("spring.ai.mcp.server.tool-change-notification=false",
"spring.ai.mcp.server.resource-change-notification=false",
"spring.ai.mcp.server.prompt-change-notification=false")
.run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.isToolChangeNotification()).isFalse();
assertThat(properties.isResourceChangeNotification()).isFalse();
assertThat(properties.isPromptChangeNotification()).isFalse();
});
}
@Test
void stdioConfiguration() {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.stdio=true").run(context -> {
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.isStdio()).isTrue();
});
}
@Test
void serverCapabilitiesConfiguration() {
this.contextRunner.run(context -> {
assertThat(context).hasSingleBean(McpSchema.ServerCapabilities.Builder.class);
McpSchema.ServerCapabilities.Builder builder = context.getBean(McpSchema.ServerCapabilities.Builder.class);
assertThat(builder).isNotNull();
});
}
@Test
void toolRegistrationConfiguration() {
this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> {
List<SyncToolSpecification> 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<AsyncToolSpecification> 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<SyncResourceRegistration> testResources() {
return List.of();
}
}
@Configuration
static class TestPromptConfiguration {
@Bean
List<SyncPromptRegistration> 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<ToolCallback> 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<List<McpSchema.Root>> rootsChangeConsumer() {
return roots -> {
// Test implementation
};
}
}
static class CustomServerTransport implements McpServerTransport {
@Override
public Mono<Void> connect(
Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> messageHandler) {
return Mono.empty(); // Test implementation
}
@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.empty(); // Test implementation
}
@Override
public <T> T unmarshalFrom(Object value, TypeReference<T> type) {
return null; // Test implementation
}
@Override
public void close() {
// Test implementation
}
@Override
public Mono<Void> closeGracefully() {
return Mono.empty(); // Test implementation
}
}
@Configuration
static class CustomTransportConfiguration {
@Bean
McpServerTransport customTransport() {
return new CustomServerTransport();
}
}
}

View File

@@ -16,30 +16,34 @@
package org.springframework.ai.mcp.server.autoconfigure;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.server.McpAsyncServer;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration;
import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceRegistration;
import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptRegistration;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.StdioServerTransport;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerTransport;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.spec.ServerMcpTransport;
import org.mockito.Mockito;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import reactor.core.publisher.Mono;
import org.springframework.ai.mcp.SyncMcpToolCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@@ -53,8 +57,9 @@ public class McpServerAutoConfigurationIT {
void defaultConfiguration() {
this.contextRunner.run(context -> {
assertThat(context).hasSingleBean(McpSyncServer.class);
assertThat(context).hasSingleBean(ServerMcpTransport.class);
assertThat(context.getBean(ServerMcpTransport.class)).isInstanceOf(StdioServerTransport.class);
assertThat(context).hasSingleBean(McpServerTransportProvider.class);
assertThat(context.getBean(McpServerTransportProvider.class))
.isInstanceOf(StdioServerTransportProvider.class);
McpServerProperties properties = context.getBean(McpServerProperties.class);
assertThat(properties.getName()).isEqualTo("mcp-server");
@@ -85,8 +90,8 @@ public class McpServerAutoConfigurationIT {
@Test
void transportConfiguration() {
this.contextRunner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> {
assertThat(context).hasSingleBean(ServerMcpTransport.class);
assertThat(context.getBean(ServerMcpTransport.class)).isInstanceOf(CustomServerTransport.class);
assertThat(context).hasSingleBean(McpServerTransport.class);
assertThat(context.getBean(McpServerTransport.class)).isInstanceOf(CustomServerTransport.class);
});
}
@@ -155,8 +160,8 @@ public class McpServerAutoConfigurationIT {
@Test
void toolRegistrationConfiguration() {
this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> {
List<SyncToolRegistration> tools = context.getBean("syncTools", List.class);
assertThat(tools).hasSize(1);
List<SyncToolSpecification> tools = context.getBean("syncTools", List.class);
assertThat(tools).hasSize(2);
});
}
@@ -181,8 +186,8 @@ public class McpServerAutoConfigurationIT {
this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC")
.withUserConfiguration(TestToolConfiguration.class)
.run(context -> {
List<AsyncToolRegistration> tools = context.getBean("asyncTools", List.class);
assertThat(tools).hasSize(1);
List<AsyncToolSpecification> tools = context.getBean("asyncTools", List.class);
assertThat(tools).hasSize(2);
});
}
@@ -196,8 +201,8 @@ public class McpServerAutoConfigurationIT {
}
@Test
void rootsChangeConsumerConfiguration() {
this.contextRunner.withUserConfiguration(TestRootsChangeConfiguration.class).run(context -> {
void rootsChangeHandlerConfiguration() {
this.contextRunner.withUserConfiguration(TestRootsHandlerConfiguration.class).run(context -> {
McpSyncServer server = context.getBean(McpSyncServer.class);
assertThat(server).isNotNull();
});
@@ -207,7 +212,7 @@ public class McpServerAutoConfigurationIT {
static class TestResourceConfiguration {
@Bean
List<SyncResourceRegistration> testResources() {
List<SyncResourceSpecification> testResources() {
return List.of();
}
@@ -217,7 +222,7 @@ public class McpServerAutoConfigurationIT {
static class TestPromptConfiguration {
@Bean
List<SyncPromptRegistration> testPrompts() {
List<SyncPromptSpecification> testPrompts() {
return List.of();
}
@@ -259,18 +264,18 @@ public class McpServerAutoConfigurationIT {
}
@Configuration
static class TestRootsChangeConfiguration {
static class TestRootsHandlerConfiguration {
@Bean
Consumer<List<McpSchema.Root>> rootsChangeConsumer() {
return roots -> {
BiConsumer<McpSyncServerExchange, List<McpSchema.Root>> rootsChangeHandler() {
return (exchange, roots) -> {
// Test implementation
};
}
}
static class CustomServerTransport implements ServerMcpTransport {
static class CustomServerTransport implements McpServerTransport {
@Override
public Mono<Void> connect(
@@ -304,7 +309,7 @@ public class McpServerAutoConfigurationIT {
static class CustomTransportConfiguration {
@Bean
ServerMcpTransport customTransport() {
McpServerTransport customTransport() {
return new CustomServerTransport();
}

View File

@@ -16,17 +16,21 @@
package org.springframework.ai.mcp;
import java.util.List;
import java.util.Map;
import io.micrometer.common.util.StringUtils;
import io.modelcontextprotocol.client.McpAsyncClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.Role;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.CollectionUtils;
@@ -46,7 +50,7 @@ import org.springframework.util.MimeType;
* <p>
* This helper class provides methods to:
* <ul>
* <li>Convert Spring AI's {@link ToolCallback} instances to MCP tool registrations</li>
* <li>Convert Spring AI's {@link ToolCallback} instances to MCP tool specification</li>
* <li>Generate JSON schemas for tool input validation</li>
* </ul>
*
@@ -63,12 +67,14 @@ public final class McpToolUtils {
throw new IllegalArgumentException("Prefix or toolName cannot be null or empty");
}
String input = prefix + "-" + toolName;
String input = prefix + "_" + toolName;
// Replace any character that isn't alphanumeric, underscore, or hyphen with
// concatenation
String formatted = input.replaceAll("[^a-zA-Z0-9_-]", "");
formatted = formatted.replaceAll("-", "_");
// If the string is longer than 64 characters, keep the last 64 characters
if (formatted.length() > 64) {
formatted = formatted.substring(formatted.length() - 64);
@@ -86,12 +92,28 @@ public final class McpToolUtils {
* @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<McpServerFeatures.SyncToolRegistration> toSyncToolRegistration(
List<ToolCallback> toolCallbacks) {
return toolCallbacks.stream().map(McpToolUtils::toSyncToolRegistration).toList();
}
/**
* Converts a list of Spring AI tool callbacks to MCP synchronous tool specificaiton.
* <p>
* This method processes multiple tool callbacks in bulk, converting each one to its
* corresponding MCP tool registration while maintaining synchronous execution
* semantics.
* @param toolCallbacks the list of tool callbacks to convert
* @return a list of MCP synchronous tool specificaiton
*/
public static List<McpServerFeatures.SyncToolSpecification> toSyncToolSpecification(
List<ToolCallback> toolCallbacks) {
return toolCallbacks.stream().map(McpToolUtils::toSyncToolSpecification).toList();
}
/**
* Convenience method to convert a variable number of tool callbacks to MCP
* synchronous tool registrations.
@@ -101,13 +123,34 @@ public final class McpToolUtils {
* @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<McpServerFeatures.SyncToolRegistration> toSyncToolRegistrations(ToolCallback... toolCallbacks) {
return toSyncToolRegistration(List.of(toolCallbacks));
}
@Deprecated
public static List<McpServerFeatures.SyncToolRegistration> toSyncToolRegistration(ToolCallback... toolCallbacks) {
return toSyncToolRegistration(List.of(toolCallbacks));
}
/**
* Converts a Spring AI FunctionCallback to an MCP SyncToolRegistration. This enables
* Convenience method to convert a variable number of tool callbacks to MCP
* synchronous tool specification.
* <p>
* This is a varargs wrapper around {@link #toSyncToolSpecification(List)} for easier
* usage when working with individual callbacks.
* @param toolCallbacks the tool callbacks to convert
* @return a list of MCP synchronous tool specificaiton
*/
public static List<McpServerFeatures.SyncToolSpecification> toSyncToolSpecifications(
ToolCallback... toolCallbacks) {
return toSyncToolSpecification(List.of(toolCallbacks));
}
/**
* Converts a Spring AI ToolCallback to an MCP SyncToolRegistration. This enables
* Spring AI functions to be exposed as MCP tools that can be discovered and invoked
* by language models.
*
@@ -121,18 +164,45 @@ public final class McpToolUtils {
* specifications</li>
* </ul>
*
* 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.
*
* <p>
* The conversion process:
* <ul>
* <li>Creates an MCP Tool with the function's name and input schema</li>
* <li>Wraps the function's execution in a SyncToolSpecification that handles the MCP
* protocol</li>
* <li>Provides error handling and result formatting according to MCP
* specifications</li>
* </ul>
*
* 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 final class McpToolUtils {
* specifications</li>
* </ul>
*
* 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 final class McpToolUtils {
});
}
/**
* 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.
*
* <p>
* The conversion process:
* <ul>
* <li>Creates an MCP Tool with the function's name and input schema</li>
* <li>Wraps the function's execution in a SyncToolSpecification that handles the MCP
* protocol</li>
* <li>Provides error handling and result formatting according to MCP
* specifications</li>
* </ul>
* @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.
* <p>
@@ -185,15 +299,32 @@ public final class McpToolUtils {
* @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<McpServerFeatures.AsyncToolRegistration> toAsyncToolRegistration(
List<ToolCallback> toolCallbacks) {
return toolCallbacks.stream().map(McpToolUtils::toAsyncToolRegistration).toList();
}
/**
* Converts a list of Spring AI tool callbacks to MCP asynchronous tool specificaiton.
* <p>
* 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<McpServerFeatures.AsyncToolSpecification> toAsyncToolSpecifications(
List<ToolCallback> 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.
* <p>
* This is a varargs wrapper around {@link #toAsyncToolRegistration(List)} for easier
* usage when working with individual callbacks.
@@ -205,6 +336,51 @@ public final class McpToolUtils {
return toAsyncToolRegistration(List.of(toolCallbacks));
}
/**
* Convenience method to convert a variable number of tool callbacks to MCP
* asynchronous tool specificaiton.
* <p>
* 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<McpServerFeatures.AsyncToolSpecification> toAsyncToolSpecifications(
ToolCallback... toolCallbacks) {
return toAsyncToolSpecifications(List.of(toolCallbacks));
}
/**
* Converts a Spring AI tool callback to an MCP asynchronous tool registration.
* <p>
* 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:
* <ul>
* <li>First converts the callback to a synchronous registration</li>
* <li>Wraps the synchronous execution in a reactive Mono</li>
* <li>Configures execution on a bounded elastic scheduler for non-blocking
* operation</li>
* </ul>
* <p>
* The resulting async registration will:
* <ul>
* <li>Execute the tool without blocking the calling thread</li>
* <li>Handle errors and results asynchronously</li>
* <li>Provide backpressure through Project Reactor</li>
* </ul>
* @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.
* <p>
@@ -229,8 +405,8 @@ public final class McpToolUtils {
* @see Mono
* @see Schedulers#boundedElastic()
*/
public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(ToolCallback toolCallback) {
return toAsyncToolRegistration(toolCallback, null);
public static McpServerFeatures.AsyncToolSpecification toAsyncToolSpecification(ToolCallback toolCallback) {
return toAsyncToolSpecification(toolCallback, null);
}
/**
@@ -257,7 +433,9 @@ public final class McpToolUtils {
* @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 final class McpToolUtils {
.subscribeOn(Schedulers.boundedElastic()));
}
/**
* Converts a Spring AI tool callback to an MCP asynchronous tool specification.
* <p>
* 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:
* <ul>
* <li>First converts the callback to a synchronous specificaiton</li>
* <li>Wraps the synchronous execution in a reactive Mono</li>
* <li>Configures execution on a bounded elastic scheduler for non-blocking
* operation</li>
* </ul>
* <p>
* The resulting async specificaiton will:
* <ul>
* <li>Execute the tool without blocking the calling thread</li>
* <li>Handle errors and results asynchronously</li>
* <li>Provide backpressure through Project Reactor</li>
* </ul>
* @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.
* <p>

View File

@@ -56,7 +56,7 @@ class SyncMcpToolCallbackTests {
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");
}

View File

@@ -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<McpToolUtils> 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<SyncToolRegistration> 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<AsyncToolRegistration> 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;
}
}

View File

@@ -21,8 +21,10 @@ 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.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.ToolCallback;
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<McpToolUtils> constructor = McpToolUtils.class.getDeclaredConstructor();
@@ -47,103 +97,94 @@ class ToolUtilsTests {
}
@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<SyncToolRegistration> registrations = McpToolUtils.toSyncToolRegistration(callback1, callback2);
List<SyncToolSpecification> 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");
assertThat(toolSpecification).isNotNull();
assertThat(toolSpecification.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();
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<AsyncToolRegistration> registrations = McpToolUtils.toAsyncToolRegistration(callback1, callback2);
List<AsyncToolSpecification> 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 @@ class ToolUtilsTests {
.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 @@ class ToolUtilsTests {
.inputSchema("{}")
.build();
when(callback.getToolDefinition()).thenReturn(definition);
when(callback.call(anyString())).thenThrow(error);
when(callback.call(anyString(), any())).thenThrow(error);
return callback;
}

View File

@@ -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<Transaction, Status> 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<String> 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<Transaction> transactions) {
}
record Statuses(List<Status> 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<FunctionCallback> toolCallbacks, ObjectProvider<ObservationRegistry> 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();
}
}
}

View File

@@ -291,7 +291,7 @@
<okhttp3.version>4.12.0</okhttp3.version>
<!-- MCP-->
<mcp.sdk.version>0.8.0-SNAPSHOT</mcp.sdk.version>
<mcp.sdk.version>0.8.0</mcp.sdk.version>
<!-- plugin versions -->
<antlr.version>4.13.1</antlr.version>

View File

@@ -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<McpSyncClient> mcpSyncClients; // For sync client
private List<McpAsyncClient> 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.

View File

@@ -88,9 +88,9 @@ Flux<ToolCallback> 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 specifications:
[tabs]
======
@@ -99,15 +99,15 @@ Sync::
[source,java]
----
List<ToolCallback> toolCallbacks = // obtain tool callbacks
List<SyncToolRegistration> syncToolRegs = McpToolUtils.toSyncToolRegistration(toolCallbacks);
List<SyncToolSpecifications> 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.SyncSpecification syncSpec = ...
syncSpec.tools(syncToolSpecs);
----
Async::
@@ -115,15 +115,15 @@ Async::
[source,java]
----
List<ToolCallback> toolCallbacks = // obtain tool callbacks
List<AsyncToolRegistration> asyncToolRegs = McpToolUtils.toAsyncToolRegistration(toolCallbacks);
List<AsyncToolSpecification> 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);
----
======

View File

@@ -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].

View File

@@ -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<McpServerFeatures.SyncToolRegistration> myTools(...) {
List<McpServerFeatures.SyncToolRegistration> tools = ...
public List<McpServerFeatures.SyncToolSpecification> myTools(...) {
List<McpServerFeatures.SyncToolSpecification> tools = ...
return tools;
}
----
@@ -148,18 +148,18 @@ public List<McpServerFeatures.SyncToolRegistration> 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<McpServerFeatures.SyncResourceRegistration> myResources(...) {
public List<McpServerFeatures.SyncResourceSpecification> 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<McpServerFeatures.SyncResourceRegistration> 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<McpServerFeatures.SyncPromptRegistration> myPrompts() {
public List<McpServerFeatures.SyncPromptSpecification> 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<List<McpSchema.Root>> rootsChangeConsumer() {
return roots -> {
public BiConsumer<McpSyncServerExchange, List<McpSchema.Root>> rootsChangeHandler() {
return (exchange, roots) -> {
logger.info("Registering root resources: {}", roots);
};
}