From 42322e176946a4ec46e48b2efa649bcdd590117f Mon Sep 17 00:00:00 2001 From: Kuntal Maity Date: Tue, 14 Oct 2025 21:46:10 +0530 Subject: [PATCH 1/2] fix(mcp): deterministic order for MCP-annotated beans (#4618) Signed-off-by: Kuntal Maity kuntal.1461@gmail.com Signed-off-by: Kuntal Maity --- ...SpecificationFactoryAutoConfiguration.java | 28 ++++--- .../annotations/SupplierBackedList.java | 70 ++++++++++++++++ .../McpClientSpecOrderingReproTests.java | 83 +++++++++++++++++++ 3 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/SupplierBackedList.java create mode 100644 auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecificationFactoryAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecificationFactoryAutoConfiguration.java index b28eac7d677..1bff7722269 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecificationFactoryAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecificationFactoryAutoConfiguration.java @@ -57,26 +57,26 @@ static class SyncClientSpecificationConfiguration { @Bean List loggingSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) { - return SyncMcpAnnotationProviders - .loggingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpLogging.class)); + return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders + .loggingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpLogging.class))); } @Bean List samplingSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) { - return SyncMcpAnnotationProviders - .samplingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpSampling.class)); + return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders + .samplingSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpSampling.class))); } @Bean List elicitationSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) { - return SyncMcpAnnotationProviders - .elicitationSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpElicitation.class)); + return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders + .elicitationSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpElicitation.class))); } @Bean List progressSpecs(ClientMcpAnnotatedBeans beansWithMcpMethodAnnotations) { - return SyncMcpAnnotationProviders - .progressSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpProgress.class)); + return new SupplierBackedList<>(() -> SyncMcpAnnotationProviders + .progressSpecifications(beansWithMcpMethodAnnotations.getBeansByAnnotation(McpProgress.class))); } } @@ -87,22 +87,26 @@ static class AsyncClientSpecificationConfiguration { @Bean List loggingSpecs(ClientMcpAnnotatedBeans beanRegistry) { - return AsyncMcpAnnotationProviders.loggingSpecifications(beanRegistry.getAllAnnotatedBeans()); + return new SupplierBackedList<>( + () -> AsyncMcpAnnotationProviders.loggingSpecifications(beanRegistry.getAllAnnotatedBeans())); } @Bean List samplingSpecs(ClientMcpAnnotatedBeans beanRegistry) { - return AsyncMcpAnnotationProviders.samplingSpecifications(beanRegistry.getAllAnnotatedBeans()); + return new SupplierBackedList<>( + () -> AsyncMcpAnnotationProviders.samplingSpecifications(beanRegistry.getAllAnnotatedBeans())); } @Bean List elicitationSpecs(ClientMcpAnnotatedBeans beanRegistry) { - return AsyncMcpAnnotationProviders.elicitationSpecifications(beanRegistry.getAllAnnotatedBeans()); + return new SupplierBackedList<>( + () -> AsyncMcpAnnotationProviders.elicitationSpecifications(beanRegistry.getAllAnnotatedBeans())); } @Bean List progressSpecs(ClientMcpAnnotatedBeans beanRegistry) { - return AsyncMcpAnnotationProviders.progressSpecifications(beanRegistry.getAllAnnotatedBeans()); + return new SupplierBackedList<>( + () -> AsyncMcpAnnotationProviders.progressSpecifications(beanRegistry.getAllAnnotatedBeans())); } } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/SupplierBackedList.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/SupplierBackedList.java new file mode 100644 index 00000000000..26cad9310b6 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/SupplierBackedList.java @@ -0,0 +1,70 @@ +/* + * 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.common.autoconfigure.annotations; + +import java.util.AbstractList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.Supplier; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * A simple {@link java.util.List} backed by a {@link Supplier} of lists. Each access + * reads from the supplier, so the contents reflect the supplier's current state. + */ +/** + * @author Kuntal Maity + */ +final class SupplierBackedList extends AbstractList { + + private final Supplier> supplier; + + SupplierBackedList(Supplier> supplier) { + this.supplier = Objects.requireNonNull(supplier, "supplier must not be null"); + } + + @Override + public T get(int index) { + return this.supplier.get().get(index); + } + + @Override + public int size() { + return this.supplier.get().size(); + } + + @Override + public Iterator iterator() { + // Iterate over a snapshot for iteration consistency + return List.copyOf(this.supplier.get()).iterator(); + } + + @Override + public Spliterator spliterator() { + return Spliterators.spliterator(iterator(), size(), Spliterator.ORDERED | Spliterator.SIZED); + } + + @Override + public Stream stream() { + return StreamSupport.stream(spliterator(), false); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java new file mode 100644 index 00000000000..2c1e66eec6c --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java @@ -0,0 +1,83 @@ +/* + * 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.common.autoconfigure.annotations; + +import java.util.List; + +import io.modelcontextprotocol.spec.McpSchema.ProgressNotification; +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpProgress; +import org.springaicommunity.mcp.method.progress.SyncProgressSpecification; +import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Reproduction test for ordering bug where McpClientSpecificationFactoryAutoConfiguration + * is created before any @Component beans with @McpProgress (or other MCP annotations) are + * instantiated, resulting in empty specification lists. + */ +class McpClientSpecOrderingReproTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpClientAnnotationScannerAutoConfiguration.class, + McpClientSpecificationFactoryAutoConfiguration.class)) + .withUserConfiguration(ScanConfig.class); + + @Configuration + @ComponentScan(basePackageClasses = ScannedClientHandlers.class) + static class ScanConfig { + + } + + @Component + @Lazy + static class ScannedClientHandlers { + + @McpProgress(clients = "server1") + public void onProgress(ProgressNotification pn) { + } + + } + + @Test + void progressSpecsIncludeScannedComponent_evenWhenCreatedAfterSpecsBean() { + runner.run(ctx -> { + // 1) Trigger spec list bean creation early + @SuppressWarnings("unchecked") + List specs = (List) ctx.getBean("progressSpecs"); + + // 2) Now force creation of the scanned @Component (post-processor runs here) + ctx.getBean(ScannedClientHandlers.class); + + // 3) Registry sees the component… + ClientMcpAnnotatedBeans registry = ctx.getBean(ClientMcpAnnotatedBeans.class); + assertThat(registry.getBeansByAnnotation(McpProgress.class)).hasSize(1); + + // 4) Expected behavior: specs reflect newly-registered handler + // Under the bug, this assertion fails (list stays empty) + assertThat(specs).hasSize(1); + }); + } + +} From b87873668755f537e21545bd8dcbb56285256119 Mon Sep 17 00:00:00 2001 From: Kuntal Maity Date: Tue, 14 Oct 2025 22:15:46 +0530 Subject: [PATCH 2/2] test(mcp): stabilize spec ordering repro for MCP-annotated beans (#4618) Signed-off-by: Kuntal Maity --- .../McpClientSpecOrderingReproTests.java | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java index 2c1e66eec6c..3d1c9f4c4ce 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpClientSpecOrderingReproTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpProgress; import org.springaicommunity.mcp.method.progress.SyncProgressSpecification; + import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration.ClientMcpAnnotatedBeans; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -44,25 +45,9 @@ class McpClientSpecOrderingReproTests { McpClientSpecificationFactoryAutoConfiguration.class)) .withUserConfiguration(ScanConfig.class); - @Configuration - @ComponentScan(basePackageClasses = ScannedClientHandlers.class) - static class ScanConfig { - - } - - @Component - @Lazy - static class ScannedClientHandlers { - - @McpProgress(clients = "server1") - public void onProgress(ProgressNotification pn) { - } - - } - @Test void progressSpecsIncludeScannedComponent_evenWhenCreatedAfterSpecsBean() { - runner.run(ctx -> { + this.runner.run(ctx -> { // 1) Trigger spec list bean creation early @SuppressWarnings("unchecked") List specs = (List) ctx.getBean("progressSpecs"); @@ -80,4 +65,20 @@ void progressSpecsIncludeScannedComponent_evenWhenCreatedAfterSpecsBean() { }); } + @Configuration + @ComponentScan(basePackageClasses = ScannedClientHandlers.class) + static class ScanConfig { + + } + + @Component + @Lazy + static class ScannedClientHandlers { + + @McpProgress(clients = "server1") + public void onProgress(ProgressNotification pn) { + } + + } + }