Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -60,45 +60,67 @@ public McpToolNamePrefixGenerator defaultMcpToolNamePrefixGenerator() {
* <p>
* These callbacks enable integration with Spring AI's tool execution framework,
* allowing MCP tools to be used as part of AI interactions.
*
* <p>
* IMPORTANT: This method receives the same list reference that is populated by
* {@link McpClientAutoConfiguration.McpSyncClientInitializer} in its
* {@code afterSingletonsInstantiated()} method. This ensures that when
* {@code getToolCallbacks()} is called, even if it's called before full
* initialization completes, it will eventually see the populated list.
* @param syncClientsToolFilter list of {@link McpToolFilter}s for the sync client to
* filter the discovered tools
* @param syncMcpClients provider of MCP sync clients
* @param syncMcpClients the MCP sync clients list (same reference as returned by
* mcpSyncClients() bean method)
* @param mcpToolNamePrefixGenerator the tool name prefix generator
* @return list of tool callbacks for MCP integration
*/
@Bean
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
matchIfMissing = true)
public SyncMcpToolCallbackProvider mcpToolCallbacks(ObjectProvider<McpToolFilter> syncClientsToolFilter,
ObjectProvider<List<McpSyncClient>> syncMcpClients,
ObjectProvider<McpToolNamePrefixGenerator> mcpToolNamePrefixGenerator,
List<McpSyncClient> syncMcpClients, ObjectProvider<McpToolNamePrefixGenerator> mcpToolNamePrefixGenerator,
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) {

List<McpSyncClient> mcpClients = syncMcpClients.stream().flatMap(List::stream).toList();

// Use mcpClientsReference to share the list reference - it will be populated by
// SmartInitializingSingleton
return SyncMcpToolCallbackProvider.builder()
.mcpClients(mcpClients)
.toolFilter(syncClientsToolFilter.getIfUnique((() -> (McpSyncClient, tool) -> true)))
.mcpClientsReference(syncMcpClients)
.toolFilter(syncClientsToolFilter.getIfUnique((() -> (mcpClient, tool) -> true)))
.toolNamePrefixGenerator(
mcpToolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix()))
.toolContextToMcpMetaConverter(
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
.build();
}

/**
* Creates async tool callbacks for all configured MCP clients.
*
* <p>
* IMPORTANT: This method receives the same list reference that is populated by
* {@link McpClientAutoConfiguration.McpAsyncClientInitializer} in its
* {@code afterSingletonsInstantiated()} method.
* @param asyncClientsToolFilter tool filter for async clients
* @param mcpClients the MCP async clients list (same reference as returned by
* mcpAsyncClients() bean method)
* @param toolNamePrefixGenerator the tool name prefix generator
* @param toolContextToMcpMetaConverter converter for tool context to MCP metadata
* @return async tool callback provider
*/
@Bean
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC")
public AsyncMcpToolCallbackProvider mcpAsyncToolCallbacks(ObjectProvider<McpToolFilter> asyncClientsToolFilter,
ObjectProvider<List<McpAsyncClient>> mcpClientsProvider,
ObjectProvider<McpToolNamePrefixGenerator> toolNamePrefixGenerator,
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) { // TODO
List<McpAsyncClient> mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList();
List<McpAsyncClient> mcpClients, ObjectProvider<McpToolNamePrefixGenerator> toolNamePrefixGenerator,
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) {

// Use mcpClientsReference to share the list reference - it will be populated by
// SmartInitializingSingleton
return AsyncMcpToolCallbackProvider.builder()
.toolFilter(asyncClientsToolFilter.getIfUnique(() -> (McpAsyncClient, tool) -> true))
.toolFilter(asyncClientsToolFilter.getIfUnique(() -> (mcpClient, tool) -> true))
.toolNamePrefixGenerator(toolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix()))
.toolContextToMcpMetaConverter(
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
.mcpClients(mcpClients)
.mcpClientsReference(mcpClients)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it can use clients instead of mcpClients. after all. the mcpClients is deprecated and mcpClientsReference is too long

.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,33 @@
import org.springframework.context.annotation.Configuration;

/**
* Auto-configuration for MCP client specification factory.
*
* <p>
* <strong>Note:</strong> This configuration is now obsolete and disabled by default.
* Specification creation has been moved to
* {@link org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.McpSyncClientInitializer}
* and
* {@link org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.McpAsyncClientInitializer}
* which use {@link org.springframework.beans.factory.SmartInitializingSingleton} to defer
* client creation until after all singleton beans have been initialized. This ensures
* that all beans with MCP-annotated methods are scanned before specifications are
* created.
*
* <p>
* This class is kept for backwards compatibility but can be safely removed in future
* versions.
*
* @author Christian Tzolov
* @author Fu Jian
* @deprecated Since 1.1.0, specifications are now created dynamically after all singleton
* beans are initialized. This class will be removed in a future release.
*/
@Deprecated(since = "1.1.0", forRemoval = true)
@AutoConfiguration(after = McpClientAnnotationScannerAutoConfiguration.class)
@ConditionalOnClass(McpLogging.class)
@ConditionalOnProperty(prefix = McpClientAnnotationScannerProperties.CONFIG_PREFIX, name = "enabled",
havingValue = "true", matchIfMissing = true)
havingValue = "false") // Disabled by default - changed from "true" to "false"
public class McpClientSpecificationFactoryAutoConfiguration {

@Configuration(proxyBeanMethods = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,28 +88,39 @@ public class McpClientAutoConfigurationIT {
AutoConfigurations.of(McpToolCallbackAutoConfiguration.class, McpClientAutoConfiguration.class));

/**
* Tests the default MCP client auto-configuration.
*
* Note: We use 'spring.ai.mcp.client.initialized=false' to prevent the
* auto-configuration from calling client.initialize() explicitly, which would cause a
* 20-second timeout waiting for real MCP protocol communication. This allows us to
* test bean creation and auto-configuration behavior without requiring a full MCP
* server connection.
* Tests that MCP clients are created after all singleton beans have been initialized,
* verifying the SmartInitializingSingleton timing behavior.
* <p>
* This test uses a LateInitBean that records its initialization timestamp, and then
* verifies that the MCP client initializer was called AFTER the late bean was
* constructed. This proves that
* SmartInitializingSingleton.afterSingletonsInstantiated() is called after all
* singleton beans (including late-initializing ones) have been fully created.
*/
@Test
void defaultConfiguration() {
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class)
void clientsCreatedAfterAllSingletons() {
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class, LateInitBeanWithTimestamp.class)
.withPropertyValues("spring.ai.mcp.client.initialized=false")
.run(context -> {
// Get the late-init bean and its construction timestamp
LateInitBeanWithTimestamp lateBean = context.getBean(LateInitBeanWithTimestamp.class);
long lateBeanTimestamp = lateBean.getInitTimestamp();

// Get the initializer and its execution timestamp
var initializer = context.getBean(McpClientAutoConfiguration.McpSyncClientInitializer.class);
long initializerTimestamp = initializer.getInitializationTimestamp();

// Verify clients were created
List<McpSyncClient> clients = context.getBean("mcpSyncClients", List.class);
assertThat(clients).hasSize(1);
assertThat(clients).isNotNull();

McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class);
assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client");
assertThat(properties.getVersion()).isEqualTo("1.0.0");
assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC);
assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20));
assertThat(properties.isInitialized()).isFalse();
// THE KEY ASSERTION: Initializer must have been called AFTER late bean
// was constructed
// This proves SmartInitializingSingleton.afterSingletonsInstantiated()
// timing
assertThat(initializerTimestamp)
.as("MCP client initializer should be called AFTER all singleton beans are initialized")
.isGreaterThan(lateBeanTimestamp);
});
}

Expand Down Expand Up @@ -224,6 +235,54 @@ void closeableWrappersCreation() {
.hasSingleBean(McpClientAutoConfiguration.CloseableMcpSyncClients.class));
}

/**
* Tests that SmartInitializingSingleton initializers are created and function
* correctly for sync clients.
*/
@Test
void smartInitializingSingletonBehavior() {
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class)
.withPropertyValues("spring.ai.mcp.client.initialized=false")
.run(context -> {
// Verify that McpSyncClientInitializer bean exists
assertThat(context).hasBean("mcpSyncClientInitializer");
assertThat(context.getBean("mcpSyncClientInitializer"))
.isInstanceOf(McpClientAutoConfiguration.McpSyncClientInitializer.class);

// Verify that clients list exists and was created by initializer
List<McpSyncClient> clients = context.getBean("mcpSyncClients", List.class);
assertThat(clients).isNotNull();

// Verify the initializer has completed
var initializer = context.getBean(McpClientAutoConfiguration.McpSyncClientInitializer.class);
assertThat(initializer.getClients()).isSameAs(clients);
});
}

/**
* Tests that SmartInitializingSingleton initializers are created and function
* correctly for async clients.
*/
@Test
void smartInitializingSingletonForAsyncClients() {
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class)
.withPropertyValues("spring.ai.mcp.client.type=ASYNC", "spring.ai.mcp.client.initialized=false")
.run(context -> {
// Verify that McpAsyncClientInitializer bean exists
assertThat(context).hasBean("mcpAsyncClientInitializer");
assertThat(context.getBean("mcpAsyncClientInitializer"))
.isInstanceOf(McpClientAutoConfiguration.McpAsyncClientInitializer.class);

// Verify that clients list exists and was created by initializer
List<McpAsyncClient> clients = context.getBean("mcpAsyncClients", List.class);
assertThat(clients).isNotNull();

// Verify the initializer has completed
var initializer = context.getBean(McpClientAutoConfiguration.McpAsyncClientInitializer.class);
assertThat(initializer.getClients()).isSameAs(clients);
});
}

@Configuration
static class TestTransportConfiguration {

Expand Down Expand Up @@ -265,6 +324,55 @@ McpSyncClientCustomizer testCustomizer() {

}

@Configuration
static class LateInitBean {

private final boolean initialized;

LateInitBean() {
// Simulate late initialization
this.initialized = true;
}

@Bean
String lateInitBean() {
// This bean method ensures the configuration is instantiated
return "late-init-marker";
}

boolean isInitialized() {
return this.initialized;
}

}

/**
* A configuration bean that records when it was initialized. Used to verify
* SmartInitializingSingleton timing - that the MCP client initializer is called AFTER
* all singleton beans (including this one) have been constructed.
*/
@Configuration
static class LateInitBeanWithTimestamp {

private final long initTimestamp;

LateInitBeanWithTimestamp() {
// Record when this bean was constructed
this.initTimestamp = System.nanoTime();
}

@Bean
String lateInitMarker() {
// This bean method ensures the configuration is instantiated
return "late-init-marker";
}

long getInitTimestamp() {
return this.initTimestamp;
}

}

static class CustomClientTransport implements McpClientTransport {

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,45 +45,42 @@
public class McpClientListChangedAnnotationsScanningIT {

private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(McpClientAnnotationScannerAutoConfiguration.class,
McpClientSpecificationFactoryAutoConfiguration.class));
.withConfiguration(AutoConfigurations.of(McpClientAnnotationScannerAutoConfiguration.class));

@ParameterizedTest
@ValueSource(strings = { "SYNC", "ASYNC" })
void shouldScanAllThreeListChangedAnnotations(String clientType) {
String prefix = clientType.toLowerCase();

this.contextRunner.withUserConfiguration(AllListChangedConfiguration.class)
.withPropertyValues("spring.ai.mcp.client.type=" + clientType)
.run(context -> {
// Verify all three annotations were scanned
// Verify all three annotations were scanned and registered
McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans annotatedBeans = context
.getBean(McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans.class);
assertThat(annotatedBeans.getBeansByAnnotation(McpToolListChanged.class)).hasSize(1);
assertThat(annotatedBeans.getBeansByAnnotation(McpResourceListChanged.class)).hasSize(1);
assertThat(annotatedBeans.getBeansByAnnotation(McpPromptListChanged.class)).hasSize(1);

// Verify all three specification beans were created
assertThat(context).hasBean(prefix + "ToolListChangedSpecs");
assertThat(context).hasBean(prefix + "ResourceListChangedSpecs");
assertThat(context).hasBean(prefix + "PromptListChangedSpecs");
// Verify the annotation scanner configuration is present
assertThat(context).hasSingleBean(McpClientAnnotationScannerAutoConfiguration.class);

// Note: Specification beans are no longer created as separate beans.
// They are now created dynamically in McpClientAutoConfiguration
// initializers
// after all singleton beans have been instantiated.
});
}

@ParameterizedTest
@ValueSource(strings = { "SYNC", "ASYNC" })
void shouldNotScanAnnotationsWhenScannerDisabled(String clientType) {
String prefix = clientType.toLowerCase();

this.contextRunner.withUserConfiguration(AllListChangedConfiguration.class)
.withPropertyValues("spring.ai.mcp.client.type=" + clientType,
"spring.ai.mcp.client.annotation-scanner.enabled=false")
.run(context -> {
// Verify scanner beans were not created
// Verify scanner configuration was not created when disabled
assertThat(context).doesNotHaveBean(McpClientAnnotationScannerAutoConfiguration.class);
assertThat(context).doesNotHaveBean(prefix + "ToolListChangedSpecs");
assertThat(context).doesNotHaveBean(prefix + "ResourceListChangedSpecs");
assertThat(context).doesNotHaveBean(prefix + "PromptListChangedSpecs");
assertThat(context)
.doesNotHaveBean(McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans.class);
});
}

Expand Down
Loading