Skip to content

Conversation

@ultramancode
Copy link

@ultramancode ultramancode commented Jan 7, 2026

feat(mcp): Add @McpClient qualifier for selective client injection

Closes #5201

Summary

This PR adds a @McpClient("connectionName") qualifier annotation that enables selective injection of individual MCP client beans (both McpSyncClient and McpAsyncClient), addressing the need for multi-agent systems where different agents require different MCP servers.

Changes

New Files

File Purpose
mcp/common/.../McpClient.java Custom qualifier annotation for MCP clients
auto-configurations/.../McpSyncClientFactory.java Shared sync client creation logic
auto-configurations/.../NamedMcpSyncClientFactoryBean.java FactoryBean for deferred sync client creation
auto-configurations/.../McpAsyncClientFactory.java Shared async client creation logic
auto-configurations/.../NamedMcpAsyncClientFactoryBean.java FactoryBean for deferred async client creation
auto-configurations/.../McpConnectionBeanRegistrar.java BeanDefinitionRegistryPostProcessor for early bean registration

Modified Files

File Change
McpClientAutoConfiguration.java Added McpSyncClientFactory, McpAsyncClientFactory, and registrar beans for individual client injection

Before & After

Scenario Before (still works) After (new option)
All sync clients List<McpSyncClient> all Same
Specific sync client Manual filtering required @McpClient("name") McpSyncClient
All async clients List<McpAsyncClient> all Same
Specific async client Manual filtering required @McpClient("name") McpAsyncClient

Note: The existing List<McpSyncClient> and List<McpAsyncClient> beans remain unchanged. This PR adds an additional injection method, not a replacement.


Usage

Sync Mode (default)

@Bean
public ChatClient fileAgent(
    ChatClient.Builder builder,
    @McpClient("filesystem") McpSyncClient fsClient
) {
    return builder
        .defaultTools(new SyncMcpToolCallbackProvider(List.of(fsClient)))
        .build();
}

Async Mode

spring.ai.mcp.client.type=ASYNC
@Bean
public ChatClient fileAgent(
    ChatClient.Builder builder,
    @McpClient("filesystem") McpAsyncClient fsClient
) {
    return builder
        .defaultTools(new AsyncMcpToolCallbackProvider(List.of(fsClient)))
        .build();
}

Technical Considerations

1. FactoryBean + BeanDefinitionRegistryPostProcessor Pattern

I tried simpler approaches first, but they all failed:

Approach Issue
@Bean Map<String, McpSyncClient> Spring's @Qualifier resolution targets individual beans in the context, not entries within a Map-typed bean. This is by design—@Qualifier uses BeanFactory.getBean(type, qualifier), not Map lookup.
beanFactory.registerSingleton() Bypasses BeanDefinition, preventing attachment of the custom @McpClient qualifier metadata. Also requires immediate object instantiation, which is impossible before dependencies are ready.
Direct client creation in Registrar BeanDefinitionRegistryPostProcessor runs before regular beans are instantiated. Dependencies like McpSyncClientConfigurer and ClientMcpSyncHandlersRegistry are not yet available.

The FactoryBean pattern solves this by:

  • Registering bean definitions early (with qualifier metadata)
  • Deferring actual client creation until dependencies are autowired

2. Setter Injection in FactoryBean

The McpConnectionBeanRegistrar only knows the connectionName at registration time. Complex dependencies like McpSyncClientConfigurer, McpClientCommonProperties, and ClientMcpSyncHandlersRegistry are not available yet. Setter injection solves this:

// In Registrar (early phase) - only connectionName is known
definition.getConstructorArgumentValues().addIndexedArgumentValue(0, connectionName);

// In FactoryBean (later phase) - Spring injects these via setters
@Autowired
public void setMcpSyncClientConfigurer(McpSyncClientConfigurer configurer) { ... }

3. Centralized Client Creation Logic

I introduced McpSyncClientFactory and McpAsyncClientFactory to encapsulate all client creation logic. This eliminates code duplication and ensures consistent configuration.

Separation of concerns:

  • Registrar: "What to register" (bean definition + metadata)
  • Factory: "How to create" (actual client instantiation)
  • FactoryBean: "When to create" (deferred until dependencies ready)

4. Conditional Sync/Async Bean Registration

McpConnectionBeanRegistrar reads spring.ai.mcp.client.type and registers the appropriate FactoryBean:

boolean isAsync = "ASYNC".equalsIgnoreCase(
    environment.getProperty("spring.ai.mcp.client.type", "SYNC"));

if (isAsync) {
    definition.setBeanClass(NamedMcpAsyncClientFactoryBean.class);
    beanName = "mcpAsyncClient_" + connectionName;
} else {
    definition.setBeanClass(NamedMcpSyncClientFactoryBean.class);
    beanName = "mcpSyncClient_" + connectionName;
}

5. Dual Qualifier Registration

Both @McpClient and @Qualifier metadata are registered for flexibility:

definition.addQualifier(new AutowireCandidateQualifier(McpClient.class, connectionName));
definition.addQualifier(new AutowireCandidateQualifier(Qualifier.class, connectionName));

Both annotations are therefore enabled by this PR, which registers individual named beans.

Annotation When to Use
@McpClient("name") Recommended — Domain-specific, self-documenting, easily searchable, extensible for future features
@Qualifier("name") Fallback for users who prefer standard Spring annotations

6. Static Bean Method for Registrar

The registrar bean method is static because BeanDefinitionRegistryPostProcessor beans must be registered very early in the Spring lifecycle. Static @Bean methods are processed before instance methods, ensuring the registrar runs before other beans are created.

@Bean
public static McpConnectionBeanRegistrar mcpConnectionBeanRegistrar(Environment environment) {
    return new McpConnectionBeanRegistrar(environment);
}

7. No Explicit destroyMethod

I initially set destroyMethod("close"), but it caused BeanDefinitionValidationException:

No method found for destroy method 'close' on class NamedMcpSyncClientFactoryBean

Spring validates destroyMethod against the FactoryBean class, not the produced object. Since McpSyncClient and McpAsyncClient are AutoCloseable, Spring automatically infers and calls close() on the created client (no explicit configuration needed).


Testing

  • Unit tests pass
  • Integration test with real MCP server

Integration Test:

Sample Configuration:

spring:
  ai:
    mcp:
      client:
        type: ASYNC  # Optional, defaults to SYNC
        sse:
          connections:
            database:
              url: http://localhost:8080/sse
        streamable-http:
          connections:
            filesystem:
              url: http://localhost:9090/mcp

Bean Registration Log(Sync):
mcp_log

Bean Registration Log(Async):
async_mcp_log

Enables selective injection of individual MCP clients by connection name
using the @mcpclient qualifier annotation. This allows different ChatClient
instances to use specific MCP servers in multi-agent systems via declarative
injection.

Changes:
- Add @mcpclient custom qualifier annotation
- Add McpConnectionBeanRegistrar for dynamic bean registration
- Add McpSyncClientFactory to centralize client creation logic
- Add NamedMcpSyncClientFactoryBean for lazy client instantiation
- Refactor McpClientAutoConfiguration to utilize the new components
- Add unit tests for the registrar and factory beans

Signed-off-by: Taewoong Kim <[email protected]>
- Add McpAsyncClientFactory for async client creation
- Add NamedMcpAsyncClientFactoryBean for named async injection
- Update McpConnectionBeanRegistrar to register async beans when type=ASYNC
- Add async client tests

Signed-off-by: Taewoong Kim <[email protected]>
@ultramancode ultramancode force-pushed the feature/mcp-client-qualifier-1.1.x branch from 01f170e to 886bc40 Compare January 9, 2026 01:24
@ultramancode
Copy link
Author

Hi @ilayaperumalg

I wanted to follow up and see if this @McpClient qualifier feature aligns with the current roadmap for Spring AI.

I originally targeted 1.1.x to match my current production environment, but observing the transition of main toward 2.0.0, I believe this feature might be better suited for the new milestone.

If you think this is a good addition to the project, would you prefer I open a fresh PR targeting main?

Thanks!

@ultramancode ultramancode changed the title feat(mcp): Add @McpClient qualifier for selective client injection feat(mcp): Add @McpClient qualifier for selective client injection Jan 12, 2026
@ilayaperumalg
Copy link
Member

@ultramancode Thanks for the PR! The MCP annotations support is part of the Spring AI community project: https://github.com/spring-ai-community/mcp-annotations. Could you check and submit a PR at mcp-annotations project instead?

@ultramancode
Copy link
Author

Thanks for the review! @ilayaperumalg

I understand your point regarding the annotations. However, I’d like to clarify the technical scope of this PR.

This PR focuses on Spring Boot Auto-Configuration and Bean Registration. The goal is to enable named bean registration and selective injection for multiple McpSyncClient / McpAsyncClient instances.

The Mcp-annotations project focuses on the method handler programming model. In contrast, this PR modifies the infrastructure layer—specifically how client beans are instantiated at boot time.

Could you please reconsider the assessment with this context in mind?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants