Skip to content

Commit 42322e1

Browse files
committed
fix(mcp): deterministic order for MCP-annotated beans (#4618)
Signed-off-by: Kuntal Maity kuntal.1461@gmail.com Signed-off-by: Kuntal Maity <kuntal.1461@gmail.com>
1 parent 940bcf3 commit 42322e1

File tree

3 files changed

+169
-12
lines changed

3 files changed

+169
-12
lines changed

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,26 @@ static class SyncClientSpecificationConfiguration {
5757

5858
@Bean
5959
List<SyncLoggingSpecification> loggingSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) {
60-
return SyncMcpAnnotationProviders
61-
.loggingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpLogging.class));
60+
return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders
61+
.loggingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpLogging.class)));
6262
}
6363

6464
@Bean
6565
List<SyncSamplingSpecification> samplingSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) {
66-
return SyncMcpAnnotationProviders
67-
.samplingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpSampling.class));
66+
return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders
67+
.samplingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpSampling.class)));
6868
}
6969

7070
@Bean
7171
List<SyncElicitationSpecification> elicitationSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) {
72-
return SyncMcpAnnotationProviders
73-
.elicitationSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpElicitation.class));
72+
return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders
73+
.elicitationSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpElicitation.class)));
7474
}
7575

7676
@Bean
7777
List<SyncProgressSpecification> progressSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) {
78-
return SyncMcpAnnotationProviders
79-
.progressSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpProgress.class));
78+
return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders
79+
.progressSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpProgress.class)));
8080
}
8181

8282
}
@@ -87,22 +87,26 @@ static class AsyncClientSpecificationConfiguration {
8787

8888
@Bean
8989
List<AsyncLoggingSpecification> loggingSpecs(ClientMcpAnnotatedBeans beanRegistry) {
90-
return AsyncMcpAnnotationProviders.loggingSpecifications(beanRegistry.getAllAnnotatedBeans());
90+
return new SupplierBackedList<>(
91+
() -> AsyncMcpAnnotationProviders.loggingSpecifications(beanRegistry.getAllAnnotatedBeans()));
9192
}
9293

9394
@Bean
9495
List<AsyncSamplingSpecification> samplingSpecs(ClientMcpAnnotatedBeans beanRegistry) {
95-
return AsyncMcpAnnotationProviders.samplingSpecifications(beanRegistry.getAllAnnotatedBeans());
96+
return new SupplierBackedList<>(
97+
() -> AsyncMcpAnnotationProviders.samplingSpecifications(beanRegistry.getAllAnnotatedBeans()));
9698
}
9799

98100
@Bean
99101
List<AsyncElicitationSpecification> elicitationSpecs(ClientMcpAnnotatedBeans beanRegistry) {
100-
return AsyncMcpAnnotationProviders.elicitationSpecifications(beanRegistry.getAllAnnotatedBeans());
102+
return new SupplierBackedList<>(
103+
() -> AsyncMcpAnnotationProviders.elicitationSpecifications(beanRegistry.getAllAnnotatedBeans()));
101104
}
102105

103106
@Bean
104107
List<AsyncProgressSpecification> progressSpecs(ClientMcpAnnotatedBeans beanRegistry) {
105-
return AsyncMcpAnnotationProviders.progressSpecifications(beanRegistry.getAllAnnotatedBeans());
108+
return new SupplierBackedList<>(
109+
() -> AsyncMcpAnnotationProviders.progressSpecifications(beanRegistry.getAllAnnotatedBeans()));
106110
}
107111

108112
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
18+
19+
import java.util.AbstractList;
20+
import java.util.Iterator;
21+
import java.util.List;
22+
import java.util.Objects;
23+
import java.util.Spliterator;
24+
import java.util.Spliterators;
25+
import java.util.function.Supplier;
26+
import java.util.stream.Stream;
27+
import java.util.stream.StreamSupport;
28+
29+
/**
30+
* A simple {@link java.util.List} backed by a {@link Supplier} of lists. Each access
31+
* reads from the supplier, so the contents reflect the supplier's current state.
32+
*/
33+
/**
34+
* @author Kuntal Maity
35+
*/
36+
final class SupplierBackedList<T> extends AbstractList<T> {
37+
38+
private final Supplier<List<T>> supplier;
39+
40+
SupplierBackedList(Supplier<List<T>> supplier) {
41+
this.supplier = Objects.requireNonNull(supplier, "supplier must not be null");
42+
}
43+
44+
@Override
45+
public T get(int index) {
46+
return this.supplier.get().get(index);
47+
}
48+
49+
@Override
50+
public int size() {
51+
return this.supplier.get().size();
52+
}
53+
54+
@Override
55+
public Iterator<T> iterator() {
56+
// Iterate over a snapshot for iteration consistency
57+
return List.copyOf(this.supplier.get()).iterator();
58+
}
59+
60+
@Override
61+
public Spliterator<T> spliterator() {
62+
return Spliterators.spliterator(iterator(), size(), Spliterator.ORDERED | Spliterator.SIZED);
63+
}
64+
65+
@Override
66+
public Stream<T> stream() {
67+
return StreamSupport.stream(spliterator(), false);
68+
}
69+
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
18+
19+
import java.util.List;
20+
21+
import io.modelcontextprotocol.spec.McpSchema.ProgressNotification;
22+
import org.junit.jupiter.api.Test;
23+
import org.springaicommunity.mcp.annotation.McpProgress;
24+
import org.springaicommunity.mcp.method.progress.SyncProgressSpecification;
25+
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans;
26+
import org.springframework.boot.autoconfigure.AutoConfigurations;
27+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
28+
import org.springframework.context.annotation.ComponentScan;
29+
import org.springframework.context.annotation.Configuration;
30+
import org.springframework.context.annotation.Lazy;
31+
import org.springframework.stereotype.Component;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
35+
/**
36+
* Reproduction test for ordering bug where McpClientSpecificationFactoryAutoConfiguration
37+
* is created before any @Component beans with @McpProgress (or other MCP annotations) are
38+
* instantiated, resulting in empty specification lists.
39+
*/
40+
class McpClientSpecOrderingReproTests {
41+
42+
private final ApplicationContextRunner runner = new ApplicationContextRunner()
43+
.withConfiguration(AutoConfigurations.of(McpClientAnnotationScannerAutoConfiguration.class,
44+
McpClientSpecificationFactoryAutoConfiguration.class))
45+
.withUserConfiguration(ScanConfig.class);
46+
47+
@Configuration
48+
@ComponentScan(basePackageClasses = ScannedClientHandlers.class)
49+
static class ScanConfig {
50+
51+
}
52+
53+
@Component
54+
@Lazy
55+
static class ScannedClientHandlers {
56+
57+
@McpProgress(clients = "server1")
58+
public void onProgress(ProgressNotification pn) {
59+
}
60+
61+
}
62+
63+
@Test
64+
void progressSpecsIncludeScannedComponent_evenWhenCreatedAfterSpecsBean() {
65+
runner.run(ctx -> {
66+
// 1) Trigger spec list bean creation early
67+
@SuppressWarnings("unchecked")
68+
List<SyncProgressSpecification> specs = (List<SyncProgressSpecification>) ctx.getBean("progressSpecs");
69+
70+
// 2) Now force creation of the scanned @Component (post-processor runs here)
71+
ctx.getBean(ScannedClientHandlers.class);
72+
73+
// 3) Registry sees the component…
74+
ClientMcpAnnotatedBeans registry = ctx.getBean(ClientMcpAnnotatedBeans.class);
75+
assertThat(registry.getBeansByAnnotation(McpProgress.class)).hasSize(1);
76+
77+
// 4) Expected behavior: specs reflect newly-registered handler
78+
// Under the bug, this assertion fails (list stays empty)
79+
assertThat(specs).hasSize(1);
80+
});
81+
}
82+
83+
}

0 commit comments

Comments
 (0)