Skip to content

Commit 872063f

Browse files
committed
Fix MCP client timing and lazy tool callback resolution
Resolves critical timing issues in MCP client initialization and tool callback resolution that prevented proper registration of MCP-annotated beans. - MCP clients created after all singleton beans initialized - Implement SmartInitializingSingleton for deferred client creation via McpSyncClientInitializer and McpAsyncClientInitializer - Tool callbacks resolved at execution instead configuration - Store ToolCallbackProvider instances in ChatClient and resolve lazily at execution time (call/stream) - Filter MCP providers from StaticToolCallbackResolver - Inconsistent list reference handling - Add mcpClientsReference() methods for proper reference sharing Breaking Changes: - MCP providers no longer in static resolver (transparent) - Client creation timing changed (transparent) Fixes: #4670, #4618 Signed-off-by: Christian Tzolov <[email protected]>
1 parent 0fdb911 commit 872063f

File tree

21 files changed

+2568
-158
lines changed

21 files changed

+2568
-158
lines changed

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java

Lines changed: 364 additions & 95 deletions
Large diffs are not rendered by default.

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpToolCallbackAutoConfiguration.java

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,45 +60,67 @@ public McpToolNamePrefixGenerator defaultMcpToolNamePrefixGenerator() {
6060
* <p>
6161
* These callbacks enable integration with Spring AI's tool execution framework,
6262
* allowing MCP tools to be used as part of AI interactions.
63+
*
64+
* <p>
65+
* IMPORTANT: This method receives the same list reference that is populated by
66+
* {@link McpClientAutoConfiguration.McpSyncClientInitializer} in its
67+
* {@code afterSingletonsInstantiated()} method. This ensures that when
68+
* {@code getToolCallbacks()} is called, even if it's called before full
69+
* initialization completes, it will eventually see the populated list.
6370
* @param syncClientsToolFilter list of {@link McpToolFilter}s for the sync client to
6471
* filter the discovered tools
65-
* @param syncMcpClients provider of MCP sync clients
72+
* @param syncMcpClients the MCP sync clients list (same reference as returned by
73+
* mcpSyncClients() bean method)
6674
* @param mcpToolNamePrefixGenerator the tool name prefix generator
6775
* @return list of tool callbacks for MCP integration
6876
*/
6977
@Bean
7078
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
7179
matchIfMissing = true)
7280
public SyncMcpToolCallbackProvider mcpToolCallbacks(ObjectProvider<McpToolFilter> syncClientsToolFilter,
73-
ObjectProvider<List<McpSyncClient>> syncMcpClients,
74-
ObjectProvider<McpToolNamePrefixGenerator> mcpToolNamePrefixGenerator,
81+
List<McpSyncClient> syncMcpClients, ObjectProvider<McpToolNamePrefixGenerator> mcpToolNamePrefixGenerator,
7582
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) {
7683

77-
List<McpSyncClient> mcpClients = syncMcpClients.stream().flatMap(List::stream).toList();
78-
84+
// Use mcpClientsReference to share the list reference - it will be populated by
85+
// SmartInitializingSingleton
7986
return SyncMcpToolCallbackProvider.builder()
80-
.mcpClients(mcpClients)
81-
.toolFilter(syncClientsToolFilter.getIfUnique((() -> (McpSyncClient, tool) -> true)))
87+
.mcpClientsReference(syncMcpClients)
88+
.toolFilter(syncClientsToolFilter.getIfUnique((() -> (mcpClient, tool) -> true)))
8289
.toolNamePrefixGenerator(
8390
mcpToolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix()))
8491
.toolContextToMcpMetaConverter(
8592
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
8693
.build();
8794
}
8895

96+
/**
97+
* Creates async tool callbacks for all configured MCP clients.
98+
*
99+
* <p>
100+
* IMPORTANT: This method receives the same list reference that is populated by
101+
* {@link McpClientAutoConfiguration.McpAsyncClientInitializer} in its
102+
* {@code afterSingletonsInstantiated()} method.
103+
* @param asyncClientsToolFilter tool filter for async clients
104+
* @param mcpClients the MCP async clients list (same reference as returned by
105+
* mcpAsyncClients() bean method)
106+
* @param toolNamePrefixGenerator the tool name prefix generator
107+
* @param toolContextToMcpMetaConverter converter for tool context to MCP metadata
108+
* @return async tool callback provider
109+
*/
89110
@Bean
90111
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC")
91112
public AsyncMcpToolCallbackProvider mcpAsyncToolCallbacks(ObjectProvider<McpToolFilter> asyncClientsToolFilter,
92-
ObjectProvider<List<McpAsyncClient>> mcpClientsProvider,
93-
ObjectProvider<McpToolNamePrefixGenerator> toolNamePrefixGenerator,
94-
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) { // TODO
95-
List<McpAsyncClient> mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList();
113+
List<McpAsyncClient> mcpClients, ObjectProvider<McpToolNamePrefixGenerator> toolNamePrefixGenerator,
114+
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) {
115+
116+
// Use mcpClientsReference to share the list reference - it will be populated by
117+
// SmartInitializingSingleton
96118
return AsyncMcpToolCallbackProvider.builder()
97-
.toolFilter(asyncClientsToolFilter.getIfUnique(() -> (McpAsyncClient, tool) -> true))
119+
.toolFilter(asyncClientsToolFilter.getIfUnique(() -> (mcpClient, tool) -> true))
98120
.toolNamePrefixGenerator(toolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.noPrefix()))
99121
.toolContextToMcpMetaConverter(
100122
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
101-
.mcpClients(mcpClients)
123+
.mcpClientsReference(mcpClients)
102124
.build();
103125
}
104126

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecificationFactoryAutoConfiguration.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,33 @@
5151
import org.springframework.context.annotation.Configuration;
5252

5353
/**
54+
* Auto-configuration for MCP client specification factory.
55+
*
56+
* <p>
57+
* <strong>Note:</strong> This configuration is now obsolete and disabled by default.
58+
* Specification creation has been moved to
59+
* {@link org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.McpSyncClientInitializer}
60+
* and
61+
* {@link org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration.McpAsyncClientInitializer}
62+
* which use {@link org.springframework.beans.factory.SmartInitializingSingleton} to defer
63+
* client creation until after all singleton beans have been initialized. This ensures
64+
* that all beans with MCP-annotated methods are scanned before specifications are
65+
* created.
66+
*
67+
* <p>
68+
* This class is kept for backwards compatibility but can be safely removed in future
69+
* versions.
70+
*
5471
* @author Christian Tzolov
5572
* @author Fu Jian
73+
* @deprecated Since 1.1.0, specifications are now created dynamically after all singleton
74+
* beans are initialized. This class will be removed in a future release.
5675
*/
76+
@Deprecated(since = "1.1.0", forRemoval = true)
5777
@AutoConfiguration(after = McpClientAnnotationScannerAutoConfiguration.class)
5878
@ConditionalOnClass(McpLogging.class)
5979
@ConditionalOnProperty(prefix = McpClientAnnotationScannerProperties.CONFIG_PREFIX, name = "enabled",
60-
havingValue = "true", matchIfMissing = true)
80+
havingValue = "false") // Disabled by default - changed from "true" to "false"
6181
public class McpClientSpecificationFactoryAutoConfiguration {
6282

6383
@Configuration(proxyBeanMethods = false)

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfigurationIT.java

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -88,28 +88,39 @@ public class McpClientAutoConfigurationIT {
8888
AutoConfigurations.of(McpToolCallbackAutoConfiguration.class, McpClientAutoConfiguration.class));
8989

9090
/**
91-
* Tests the default MCP client auto-configuration.
92-
*
93-
* Note: We use 'spring.ai.mcp.client.initialized=false' to prevent the
94-
* auto-configuration from calling client.initialize() explicitly, which would cause a
95-
* 20-second timeout waiting for real MCP protocol communication. This allows us to
96-
* test bean creation and auto-configuration behavior without requiring a full MCP
97-
* server connection.
91+
* Tests that MCP clients are created after all singleton beans have been initialized,
92+
* verifying the SmartInitializingSingleton timing behavior.
93+
* <p>
94+
* This test uses a LateInitBean that records its initialization timestamp, and then
95+
* verifies that the MCP client initializer was called AFTER the late bean was
96+
* constructed. This proves that
97+
* SmartInitializingSingleton.afterSingletonsInstantiated() is called after all
98+
* singleton beans (including late-initializing ones) have been fully created.
9899
*/
99100
@Test
100-
void defaultConfiguration() {
101-
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class)
101+
void clientsCreatedAfterAllSingletons() {
102+
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class, LateInitBeanWithTimestamp.class)
102103
.withPropertyValues("spring.ai.mcp.client.initialized=false")
103104
.run(context -> {
105+
// Get the late-init bean and its construction timestamp
106+
LateInitBeanWithTimestamp lateBean = context.getBean(LateInitBeanWithTimestamp.class);
107+
long lateBeanTimestamp = lateBean.getInitTimestamp();
108+
109+
// Get the initializer and its execution timestamp
110+
var initializer = context.getBean(McpClientAutoConfiguration.McpSyncClientInitializer.class);
111+
long initializerTimestamp = initializer.getInitializationTimestamp();
112+
113+
// Verify clients were created
104114
List<McpSyncClient> clients = context.getBean("mcpSyncClients", List.class);
105-
assertThat(clients).hasSize(1);
115+
assertThat(clients).isNotNull();
106116

107-
McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class);
108-
assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client");
109-
assertThat(properties.getVersion()).isEqualTo("1.0.0");
110-
assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC);
111-
assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20));
112-
assertThat(properties.isInitialized()).isFalse();
117+
// THE KEY ASSERTION: Initializer must have been called AFTER late bean
118+
// was constructed
119+
// This proves SmartInitializingSingleton.afterSingletonsInstantiated()
120+
// timing
121+
assertThat(initializerTimestamp)
122+
.as("MCP client initializer should be called AFTER all singleton beans are initialized")
123+
.isGreaterThan(lateBeanTimestamp);
113124
});
114125
}
115126

@@ -224,6 +235,54 @@ void closeableWrappersCreation() {
224235
.hasSingleBean(McpClientAutoConfiguration.CloseableMcpSyncClients.class));
225236
}
226237

238+
/**
239+
* Tests that SmartInitializingSingleton initializers are created and function
240+
* correctly for sync clients.
241+
*/
242+
@Test
243+
void smartInitializingSingletonBehavior() {
244+
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class)
245+
.withPropertyValues("spring.ai.mcp.client.initialized=false")
246+
.run(context -> {
247+
// Verify that McpSyncClientInitializer bean exists
248+
assertThat(context).hasBean("mcpSyncClientInitializer");
249+
assertThat(context.getBean("mcpSyncClientInitializer"))
250+
.isInstanceOf(McpClientAutoConfiguration.McpSyncClientInitializer.class);
251+
252+
// Verify that clients list exists and was created by initializer
253+
List<McpSyncClient> clients = context.getBean("mcpSyncClients", List.class);
254+
assertThat(clients).isNotNull();
255+
256+
// Verify the initializer has completed
257+
var initializer = context.getBean(McpClientAutoConfiguration.McpSyncClientInitializer.class);
258+
assertThat(initializer.getClients()).isSameAs(clients);
259+
});
260+
}
261+
262+
/**
263+
* Tests that SmartInitializingSingleton initializers are created and function
264+
* correctly for async clients.
265+
*/
266+
@Test
267+
void smartInitializingSingletonForAsyncClients() {
268+
this.contextRunner.withUserConfiguration(TestTransportConfiguration.class)
269+
.withPropertyValues("spring.ai.mcp.client.type=ASYNC", "spring.ai.mcp.client.initialized=false")
270+
.run(context -> {
271+
// Verify that McpAsyncClientInitializer bean exists
272+
assertThat(context).hasBean("mcpAsyncClientInitializer");
273+
assertThat(context.getBean("mcpAsyncClientInitializer"))
274+
.isInstanceOf(McpClientAutoConfiguration.McpAsyncClientInitializer.class);
275+
276+
// Verify that clients list exists and was created by initializer
277+
List<McpAsyncClient> clients = context.getBean("mcpAsyncClients", List.class);
278+
assertThat(clients).isNotNull();
279+
280+
// Verify the initializer has completed
281+
var initializer = context.getBean(McpClientAutoConfiguration.McpAsyncClientInitializer.class);
282+
assertThat(initializer.getClients()).isSameAs(clients);
283+
});
284+
}
285+
227286
@Configuration
228287
static class TestTransportConfiguration {
229288

@@ -265,6 +324,55 @@ McpSyncClientCustomizer testCustomizer() {
265324

266325
}
267326

327+
@Configuration
328+
static class LateInitBean {
329+
330+
private final boolean initialized;
331+
332+
LateInitBean() {
333+
// Simulate late initialization
334+
this.initialized = true;
335+
}
336+
337+
@Bean
338+
String lateInitBean() {
339+
// This bean method ensures the configuration is instantiated
340+
return "late-init-marker";
341+
}
342+
343+
boolean isInitialized() {
344+
return this.initialized;
345+
}
346+
347+
}
348+
349+
/**
350+
* A configuration bean that records when it was initialized. Used to verify
351+
* SmartInitializingSingleton timing - that the MCP client initializer is called AFTER
352+
* all singleton beans (including this one) have been constructed.
353+
*/
354+
@Configuration
355+
static class LateInitBeanWithTimestamp {
356+
357+
private final long initTimestamp;
358+
359+
LateInitBeanWithTimestamp() {
360+
// Record when this bean was constructed
361+
this.initTimestamp = System.nanoTime();
362+
}
363+
364+
@Bean
365+
String lateInitMarker() {
366+
// This bean method ensures the configuration is instantiated
367+
return "late-init-marker";
368+
}
369+
370+
long getInitTimestamp() {
371+
return this.initTimestamp;
372+
}
373+
374+
}
375+
268376
static class CustomClientTransport implements McpClientTransport {
269377

270378
@Override

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientListChangedAnnotationsScanningIT.java

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,45 +45,42 @@
4545
public class McpClientListChangedAnnotationsScanningIT {
4646

4747
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
48-
.withConfiguration(AutoConfigurations.of(McpClientAnnotationScannerAutoConfiguration.class,
49-
McpClientSpecificationFactoryAutoConfiguration.class));
48+
.withConfiguration(AutoConfigurations.of(McpClientAnnotationScannerAutoConfiguration.class));
5049

5150
@ParameterizedTest
5251
@ValueSource(strings = { "SYNC", "ASYNC" })
5352
void shouldScanAllThreeListChangedAnnotations(String clientType) {
54-
String prefix = clientType.toLowerCase();
55-
5653
this.contextRunner.withUserConfiguration(AllListChangedConfiguration.class)
5754
.withPropertyValues("spring.ai.mcp.client.type=" + clientType)
5855
.run(context -> {
59-
// Verify all three annotations were scanned
56+
// Verify all three annotations were scanned and registered
6057
McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans annotatedBeans = context
6158
.getBean(McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans.class);
6259
assertThat(annotatedBeans.getBeansByAnnotation(McpToolListChanged.class)).hasSize(1);
6360
assertThat(annotatedBeans.getBeansByAnnotation(McpResourceListChanged.class)).hasSize(1);
6461
assertThat(annotatedBeans.getBeansByAnnotation(McpPromptListChanged.class)).hasSize(1);
6562

66-
// Verify all three specification beans were created
67-
assertThat(context).hasBean(prefix + "ToolListChangedSpecs");
68-
assertThat(context).hasBean(prefix + "ResourceListChangedSpecs");
69-
assertThat(context).hasBean(prefix + "PromptListChangedSpecs");
63+
// Verify the annotation scanner configuration is present
64+
assertThat(context).hasSingleBean(McpClientAnnotationScannerAutoConfiguration.class);
65+
66+
// Note: Specification beans are no longer created as separate beans.
67+
// They are now created dynamically in McpClientAutoConfiguration
68+
// initializers
69+
// after all singleton beans have been instantiated.
7070
});
7171
}
7272

7373
@ParameterizedTest
7474
@ValueSource(strings = { "SYNC", "ASYNC" })
7575
void shouldNotScanAnnotationsWhenScannerDisabled(String clientType) {
76-
String prefix = clientType.toLowerCase();
77-
7876
this.contextRunner.withUserConfiguration(AllListChangedConfiguration.class)
7977
.withPropertyValues("spring.ai.mcp.client.type=" + clientType,
8078
"spring.ai.mcp.client.annotation-scanner.enabled=false")
8179
.run(context -> {
82-
// Verify scanner beans were not created
80+
// Verify scanner configuration was not created when disabled
8381
assertThat(context).doesNotHaveBean(McpClientAnnotationScannerAutoConfiguration.class);
84-
assertThat(context).doesNotHaveBean(prefix + "ToolListChangedSpecs");
85-
assertThat(context).doesNotHaveBean(prefix + "ResourceListChangedSpecs");
86-
assertThat(context).doesNotHaveBean(prefix + "PromptListChangedSpecs");
82+
assertThat(context)
83+
.doesNotHaveBean(McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans.class);
8784
});
8885
}
8986

0 commit comments

Comments
 (0)