spring-projects / spring-projects/spring-ai

MCP annotation scanner re-scans every uncached `FactoryBean` product on each `getBean` call, under the singleton lock

Open
#6,961 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
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

  1. Create a Spring Boot application with spring-ai-starter-mcp-server-webmvc.
  2. Declare a SmartFactoryBean whose product carries no MCP annotation at all. Make the product implement TargetClassAware so it can count how many times a post-processor asks for its target class: Spring AI's scanner does so on every postProcessAfterInitialization call, right before scanning the class methods.
  3. After startup, call applicationContext.getBean(Repository.class) 1000 times.
  4. 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 (nonAnnotatedClasses pattern).
  • 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.