spring-projects / spring-projects/spring-ai
MCP annotation scanner re-scans every uncached `FactoryBean` product on each `getBean` call, under the singleton lock
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 9.5k
- Forks
- 2.9k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 5
Description
Bug description
McpServerAnnotationScannerAutoConfiguration registers ServerAnnotatedMethodBeanPostProcessor, a BeanPostProcessor whose postProcessAfterInitialization reflectively scans the whole method hierarchy of every bean handed to it (AnnotatedMethodDiscovery.scan -> ReflectionUtils.doWithMethods -> AnnotationUtils.findAnnotation).
Spring never caches the product of a SmartFactoryBean (see FactoryBeanRegistrySupport.getObjectFromFactoryBean, "A SmartFactoryBean may return multiple object types -> do not cache"). Every getBean resolving to such a product therefore runs postProcessObjectFromSingletonFactoryBean, hence the full MCP annotation scan, while holding the bean factory singleton lock. The same applies to non-singleton FactoryBean products and prototype beans.
In hot paths where code makes SmartFactoryBean dependent applicationContext.getBean() calls, adding spring-ai-starter-mcp-server-webmvc led to application freeze: 200+ request threads parked on FactoryBeanRegistrySupport.getObjectFromFactoryBean, the lock holder being RUNNABLE in the MCP scanner. Setting spring.ai.mcp.server.annotation-scanner.enabled=false immediately resolved it, at the cost of losing all annotated tools.
Representative lock holder stack (Spring AI 2.0.0, Spring Framework 7.0.8):
org.springframework.core.annotation.AnnotationsScanner.processMethodHierarchy
org.springframework.core.annotation.AnnotationUtils.findAnnotation
org.springframework.util.ReflectionUtils.doWithMethods
org.springframework.ai.mcp.annotation.spring.scan.AnnotatedMethodDiscovery.scan
org.springframework.ai.mcp.annotation.spring.scan.AbstractAnnotatedMethodBeanPostProcessor.postProcessAfterInitialization
org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.applyBeanPostProcessorsAfterInitialization
org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.postProcessObjectFromFactoryBean
org.springframework.beans.factory.support.FactoryBeanRegistrySupport.postProcessObjectFromSingletonFactoryBean
org.springframework.beans.factory.support.FactoryBeanRegistrySupport.getObjectFromFactoryBean
org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean
org.springframework.beans.factory.support.AbstractBeanFactory.getBean
org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveNamedBean
org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean
Environment
- Spring AI 2.0.0 (
spring-ai-starter-mcp-server-webmvc) - Spring Boot 4.1.0, Spring Framework 7.0.8
- Java 25
Steps to reproduce
- Create a Spring Boot application with
spring-ai-starter-mcp-server-webmvc. - Declare a
SmartFactoryBeanwhose product carries no MCP annotation at all. Make the product implementTargetClassAwareso it can count how many times a post-processor asks for its target class: Spring AI's scanner does so on everypostProcessAfterInitializationcall, right before scanning the class methods. - After startup, call
applicationContext.getBean(Repository.class)1000 times. - The product is inspected 1000 times. Run again with
spring.ai.mcp.server.annotation-scanner.enabled=false: 0 times.
Expected behavior
- A bean class is scanned at most once. Classes without MCP annotations are remembered and skipped (
nonAnnotatedClassespattern). - No expensive reflection runs while holding the singleton lock on the application's hot paths.
Minimal Complete Reproducible example
Run against Spring Boot 4.1.0 (Spring Framework 7.0.8) and stock Spring AI 2.0.0 (spring-boot-starter-webmvc + spring-ai-starter-mcp-server-webmvc), it prints:
Inspections per 1000 lookups, scanner enabled: 1000
Inspections per 1000 lookups, scanner disabled: 0
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.aop.TargetClassAware;
import org.springframework.beans.factory.SmartFactoryBean;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
System.out.println("Inspections per 1000 lookups, scanner enabled: " + inspectionsPerLookups(true));
System.out.println("Inspections per 1000 lookups, scanner disabled: " + inspectionsPerLookups(false));
}
private static int inspectionsPerLookups(boolean scannerEnabled) {
SpringApplication application = new SpringApplication(DemoApplication.class);
application.setDefaultProperties(Map.of(
"spring.ai.mcp.server.annotation-scanner.enabled", String.valueOf(scannerEnabled),
"server.port", "0"));
try (ConfigurableApplicationContext context = application.run()) {
Repository repository = context.getBean(Repository.class);
int before = repository.inspections.get();
for (int i = 0; i < 1000; i++) {
context.getBean(Repository.class);
}
return repository.inspections.get() - before;
}
}
/**
* No MCP annotation anywhere. {@link TargetClassAware} only lets the bean count how many times a
* post-processor asks for its target class: Spring AI's scanner does so unconditionally, right
* before scanning the class methods (AbstractAnnotatedMethodBeanPostProcessor#postProcessAfterInitialization).
*/
static class Repository implements TargetClassAware {
final AtomicInteger inspections = new AtomicInteger();
@Override
public Class<?> getTargetClass() {
inspections.incrementAndGet();
return Repository.class;
}
}
/** Spring never caches a SmartFactoryBean product: every lookup post-processes it again. */
static class RepositoryFactoryBean implements SmartFactoryBean<Repository> {
private final Repository repository = new Repository();
@Override
public Repository getObject() {
return repository;
}
@Override
public Class<?> getObjectType() {
return Repository.class;
}
}
@Bean
RepositoryFactoryBean repository() {
return new RepositoryFactoryBean();
}
}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with AbstractAnnotatedMethodBeanPostProcessor.postProcessAfterInitialization and follow AnnotatedMethodDiscovery.scan through the reported Spring bean-factory path. Run the minimal example with 1000 applicationContext.getBean(Repository.class) calls; done means uncached FactoryBean products are not repeatedly inspected and expensive MCP reflection is avoided on the hot path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, spring, spring-boot
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100