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..3d1c9f4c4ce --- /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,84 @@ +/* + * 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); + + @Test + void progressSpecsIncludeScannedComponent_evenWhenCreatedAfterSpecsBean() { + this.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); + }); + } + + @Configuration + @ComponentScan(basePackageClasses = ScannedClientHandlers.class) + static class ScanConfig { + + } + + @Component + @Lazy + static class ScannedClientHandlers { + + @McpProgress(clients = "server1") + public void onProgress(ProgressNotification pn) { + } + + } + +}