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:
committed by
Mark Pollack
parent
3ca8d703df
commit
fc955c74eb
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
2
pom.xml
2
pom.xml
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
----
|
||||
======
|
||||
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user