Spring Boot starter providing auto-configuration for Model Context Protocol (MCP) client with Spring WebFlux, enabling reactive AI applications to connect to MCP servers via SSE and Streamable HTTP transports
Annotation-based handler scanning for Model Context Protocol (MCP) client operations, enabling declarative MCP handler methods using Spring annotations.
The annotation scanner auto-configuration automatically discovers and registers Spring bean methods annotated with MCP annotations. This provides a declarative way to handle MCP protocol events and operations without manually implementing handler interfaces.
When enabled (default), the scanner looks for methods annotated with MCP annotations like @McpLogging, @McpSampling, @McpProgress, and others. These methods are automatically registered with the appropriate MCP client handler registry.
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
/**
* Auto-configuration for annotation-based MCP handler scanning.
* Scans for annotated methods and registers them with handler registries.
* Enabled by default unless explicitly disabled.
* Thread-safe - scanning happens during Spring context initialization.
*/
@org.springframework.boot.autoconfigure.AutoConfiguration
@org.springframework.boot.autoconfigure.condition.ConditionalOnClass(
org.springaicommunity.mcp.annotation.McpLogging.class
)
@org.springframework.boot.context.properties.EnableConfigurationProperties(
McpClientAnnotationScannerProperties.class
)
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
prefix = "spring.ai.mcp.client.annotation-scanner",
name = "enabled",
havingValue = "true",
matchIfMissing = true
)
public class McpClientAnnotationScannerAutoConfiguration {
}This auto-configuration is conditionally enabled when:
org.springaicommunity.mcp.annotation.McpLogging is on the classpath (from spring-ai-mcp-annotations dependency)spring.ai.mcp.client.annotation-scanner.enabled is true (default) or not specifiedIf these conditions are not met, annotation scanning is skipped and no handler registries are created.
Required Maven dependency:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-annotations</artifactId>
<version>${spring-ai.version}</version>
</dependency>Or Gradle:
implementation 'org.springframework.ai:spring-ai-mcp-annotations:${springAiVersion}'package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
/**
* Registry for synchronous MCP handler methods discovered via annotation scanning.
* Used by synchronous MCP clients to register annotated handler methods.
* Created only when spring.ai.mcp.client.type=SYNC (default).
* Thread-safe for concurrent registration and access.
* Immutable after registration phase completes.
*
* @return ClientMcpSyncHandlersRegistry instance (never null)
*/
@org.springframework.context.annotation.Bean
@org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
prefix = "spring.ai.mcp.client",
name = "type",
havingValue = "SYNC",
matchIfMissing = true
)
public org.springframework.ai.mcp.annotation.spring.ClientMcpSyncHandlersRegistry clientMcpSyncHandlersRegistry();Created when: spring.ai.mcp.client.type=SYNC (default)
Registry Characteristics:
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
/**
* Registry for asynchronous MCP handler methods discovered via annotation scanning.
* Used by asynchronous MCP clients to register annotated handler methods.
* Created only when spring.ai.mcp.client.type=ASYNC.
* Thread-safe for concurrent registration and access.
* Immutable after registration phase completes.
*
* @return ClientMcpAsyncHandlersRegistry instance (never null)
*/
@org.springframework.context.annotation.Bean
@org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
prefix = "spring.ai.mcp.client",
name = "type",
havingValue = "ASYNC"
)
public org.springframework.ai.mcp.annotation.spring.ClientMcpAsyncHandlersRegistry clientMcpAsyncHandlersRegistry();Created when: spring.ai.mcp.client.type=ASYNC
Registry Characteristics:
The annotation scanner recognizes the following MCP annotations from the org.springaicommunity.mcp.annotation package:
Marks a method as a handler for MCP logging operations. Use this to implement custom logging logic for MCP protocol messages.
Package: org.springaicommunity.mcp.annotation.McpLogging
Method Signature (Sync):
@McpLogging
public void handleLogging(String connectionName, String level, String message);
// OR with additional parameters
@McpLogging
public void handleLogging(String connectionName, String level, String message, Map<String, Object> data);Method Signature (Async):
@McpLogging
public Mono<Void> handleLogging(String connectionName, String level, String message);
// OR
@McpLogging
public Mono<Void> handleLogging(String connectionName, String level, String message, Map<String, Object> data);Parameters:
connectionName: Name of the MCP connection (never null)level: Log level (e.g., "debug", "info", "warning", "error") (never null)message: Log message content (may be null)data: Optional additional log data (may be null)Marks a method as a handler for MCP sampling operations. Use this to handle AI model sampling requests from MCP servers.
Package: org.springaicommunity.mcp.annotation.McpSampling
Method Signature (Sync):
@McpSampling
public McpSchema.CreateMessageResult handleSampling(
String connectionName,
McpSchema.CreateMessageRequest request
);Method Signature (Async):
@McpSampling
public Mono<McpSchema.CreateMessageResult> handleSampling(
String connectionName,
McpSchema.CreateMessageRequest request
);Parameters:
connectionName: Name of the MCP connection (never null)request: Sampling request with messages and model preferences (never null)Return Value:
CreateMessageResult: Generated message result with role, content, and stop reasonMarks a method as a handler for MCP elicitation operations. Use this to handle information elicitation requests from MCP servers.
Package: org.springaicommunity.mcp.annotation.McpElicitation
Method Signature (Sync):
@McpElicitation
public String handleElicitation(String connectionName, String prompt, Map<String, Object> context);Method Signature (Async):
@McpElicitation
public Mono<String> handleElicitation(String connectionName, String prompt, Map<String, Object> context);Parameters:
connectionName: Name of the MCP connection (never null)prompt: Elicitation prompt text (never null)context: Additional context data (may be null)Return Value:
String: Elicited information responseMarks a method as a handler for MCP progress notifications. Use this to track and respond to progress updates during long-running operations.
Package: org.springaicommunity.mcp.annotation.McpProgress
Method Signature (Sync):
@McpProgress
public void handleProgress(String connectionName, long current, long total, String progressToken);
// OR simplified
@McpProgress
public void handleProgress(String connectionName, long current, long total);Method Signature (Async):
@McpProgress
public Mono<Void> handleProgress(String connectionName, long current, long total, String progressToken);Parameters:
connectionName: Name of the MCP connection (never null)current: Current progress value (0-based)total: Total progress value (max value)progressToken: Optional progress tracking token (may be null)Marks a method as a handler for MCP tool list change notifications. Use this to respond when the available tools on an MCP server change.
Package: org.springaicommunity.mcp.annotation.McpToolListChanged
Method Signature (Sync):
@McpToolListChanged
public void handleToolListChanged(String connectionName, List<McpSchema.Tool> tools);Method Signature (Async):
@McpToolListChanged
public Mono<Void> handleToolListChanged(String connectionName, List<McpSchema.Tool> tools);Parameters:
connectionName: Name of the MCP connection (never null)tools: Updated list of available tools (never null, may be empty)Tool Properties:
name(): Tool name (unique within server)description(): Tool description (may be null)inputSchema(): JSON schema for tool parametersMarks a method as a handler for MCP resource list change notifications. Use this to respond when the available resources on an MCP server change.
Package: org.springaicommunity.mcp.annotation.McpResourceListChanged
Method Signature (Sync):
@McpResourceListChanged
public void handleResourceListChanged(String connectionName, List<McpSchema.Resource> resources);Method Signature (Async):
@McpResourceListChanged
public Mono<Void> handleResourceListChanged(String connectionName, List<McpSchema.Resource> resources);Parameters:
connectionName: Name of the MCP connection (never null)resources: Updated list of available resources (never null, may be empty)Resource Properties:
uri(): Resource URI (unique identifier)name(): Human-readable resource namedescription(): Resource description (may be null)mimeType(): MIME type (may be null)Marks a method as a handler for MCP prompt list change notifications. Use this to respond when the available prompts on an MCP server change.
Package: org.springaicommunity.mcp.annotation.McpPromptListChanged
Method Signature (Sync):
@McpPromptListChanged
public void handlePromptListChanged(String connectionName, List<McpSchema.Prompt> prompts);Method Signature (Async):
@McpPromptListChanged
public Mono<Void> handlePromptListChanged(String connectionName, List<McpSchema.Prompt> prompts);Parameters:
connectionName: Name of the MCP connection (never null)prompts: Updated list of available prompts (never null, may be empty)Prompt Properties:
name(): Prompt name (unique within server)description(): Prompt description (may be null)arguments(): List of prompt argument definitionsConfigure annotation scanning in application.yml:
spring.ai.mcp.client:
annotation-scanner:
enabled: true # defaultTo disable annotation scanning:
spring.ai.mcp.client:
annotation-scanner:
enabled: falseSee Configuration Properties for complete property details.
import org.springaicommunity.mcp.annotation.McpLogging;
import org.springframework.stereotype.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Handler for MCP logging events.
* Receives log messages from all connected MCP servers.
* Thread-safe - can be invoked concurrently from multiple connections.
*/
@Component
public class McpLoggingHandler {
private static final Logger log = LoggerFactory.getLogger(McpLoggingHandler.class);
/**
* Handle logging messages from MCP servers.
* Invoked synchronously when servers send log notifications.
*
* @param connectionName Name of the connection sending the log
* @param level Log level (debug, info, warning, error)
* @param message Log message content
*/
@McpLogging
public void handleLogging(String connectionName, String level, String message) {
// Map MCP log levels to SLF4J levels
switch (level.toLowerCase()) {
case "debug" -> log.debug("[{}] {}", connectionName, message);
case "info" -> log.info("[{}] {}", connectionName, message);
case "warning" -> log.warn("[{}] {}", connectionName, message);
case "error" -> log.error("[{}] {}", connectionName, message);
default -> log.info("[{}] [{}] {}", connectionName, level, message);
}
}
}import org.springaicommunity.mcp.annotation.McpProgress;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Handler for MCP progress notifications.
* Tracks progress for long-running operations across all connections.
* Thread-safe with concurrent map for progress tracking.
*/
@Component
public class McpProgressHandler {
// Track progress per connection and token
private final Map<String, Double> progressCache = new ConcurrentHashMap<>();
/**
* Handle progress notifications from MCP servers.
* Invoked when servers report progress for long-running operations.
*
* @param connectionName Name of the connection reporting progress
* @param current Current progress value
* @param total Total progress value
* @param progressToken Optional token identifying the operation
*/
@McpProgress
public void handleProgress(String connectionName, long current, long total, String progressToken) {
double percentage = (current * 100.0) / total;
String key = progressToken != null
? connectionName + ":" + progressToken
: connectionName;
progressCache.put(key, percentage);
System.out.printf("Progress on %s: %.2f%% (%d/%d)%n",
connectionName, percentage, current, total);
// Remove from cache when complete
if (current >= total) {
progressCache.remove(key);
}
}
/**
* Get current progress for a connection.
*
* @param connectionName Connection to check
* @return Progress percentage (0-100), or null if no progress tracked
*/
public Double getProgress(String connectionName) {
return progressCache.get(connectionName);
}
}import io.modelcontextprotocol.spec.McpSchema;
import org.springaicommunity.mcp.annotation.McpToolListChanged;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Handler for MCP tool list changes.
* Maintains a cache of available tools per connection.
* Thread-safe with concurrent map.
*/
@Component
public class McpToolChangeHandler {
// Cache of tools per connection
private final Map<String, List<McpSchema.Tool>> toolsCache = new ConcurrentHashMap<>();
/**
* Handle tool list change notifications.
* Invoked when MCP servers add, remove, or modify their tool list.
*
* @param connectionName Name of the connection with changed tools
* @param tools Updated list of tools
*/
@McpToolListChanged
public void handleToolListChanged(String connectionName, List<McpSchema.Tool> tools) {
toolsCache.put(connectionName, List.copyOf(tools)); // Defensive copy
System.out.println("Tools changed for connection: " + connectionName);
System.out.println("Available tools: " + tools.size());
tools.forEach(tool -> {
System.out.printf(" - %s: %s%n", tool.name(),
tool.description() != null ? tool.description() : "No description");
});
}
/**
* Get cached tools for a connection.
*
* @param connectionName Connection to query
* @return List of tools, or empty list if connection not found
*/
public List<McpSchema.Tool> getTools(String connectionName) {
return toolsCache.getOrDefault(connectionName, List.of());
}
/**
* Check if a specific tool is available on a connection.
*
* @param connectionName Connection to check
* @param toolName Tool name to look for
* @return true if tool is available
*/
public boolean hasTool(String connectionName, String toolName) {
return getTools(connectionName).stream()
.anyMatch(tool -> tool.name().equals(toolName));
}
}import io.modelcontextprotocol.spec.McpSchema;
import org.springaicommunity.mcp.annotation.McpResourceListChanged;
import org.springframework.stereotype.Component;
import org.springframework.context.ApplicationEventPublisher;
import java.util.List;
/**
* Handler for MCP resource list changes.
* Publishes Spring events when resources change for downstream processing.
*/
@Component
public class McpResourceChangeHandler {
private final ApplicationEventPublisher eventPublisher;
public McpResourceChangeHandler(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
/**
* Handle resource list change notifications.
* Publishes a custom Spring event for other components to react to.
*
* @param connectionName Name of the connection with changed resources
* @param resources Updated list of resources
*/
@McpResourceListChanged
public void handleResourceListChanged(String connectionName,
List<McpSchema.Resource> resources) {
System.out.println("Resources changed for connection: " + connectionName);
System.out.println("Available resources: " + resources.size());
resources.forEach(resource ->
System.out.printf(" - %s (%s): %s%n",
resource.name(),
resource.uri(),
resource.mimeType() != null ? resource.mimeType() : "unknown type")
);
// Publish custom event for other components
eventPublisher.publishEvent(new ResourcesChangedEvent(this, connectionName, resources));
}
// Custom event class (should be defined in appropriate package)
public static class ResourcesChangedEvent extends org.springframework.context.ApplicationEvent {
private final String connectionName;
private final List<McpSchema.Resource> resources;
public ResourcesChangedEvent(Object source, String connectionName, List<McpSchema.Resource> resources) {
super(source);
this.connectionName = connectionName;
this.resources = resources;
}
public String getConnectionName() { return connectionName; }
public List<McpSchema.Resource> getResources() { return resources; }
}
}import io.modelcontextprotocol.spec.McpSchema;
import org.springaicommunity.mcp.annotation.McpPromptListChanged;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* Handler for MCP prompt list changes.
* Logs available prompts and their arguments for debugging.
*/
@Component
public class McpPromptChangeHandler {
/**
* Handle prompt list change notifications.
* Provides detailed logging of prompts and their argument schemas.
*
* @param connectionName Name of the connection with changed prompts
* @param prompts Updated list of prompts
*/
@McpPromptListChanged
public void handlePromptListChanged(String connectionName,
List<McpSchema.Prompt> prompts) {
System.out.println("Prompts changed for connection: " + connectionName);
System.out.println("Available prompts: " + prompts.size());
prompts.forEach(prompt -> {
System.out.printf(" - %s: %s%n", prompt.name(),
prompt.description() != null ? prompt.description() : "No description");
// Log prompt arguments if present
if (prompt.arguments() != null && !prompt.arguments().isEmpty()) {
String args = prompt.arguments().stream()
.map(arg -> arg.name() + (arg.required() ? "*" : ""))
.collect(Collectors.joining(", "));
System.out.printf(" Arguments: %s%n", args);
}
});
}
}import io.modelcontextprotocol.spec.McpSchema;
import org.springaicommunity.mcp.annotation.McpSampling;
import org.springframework.stereotype.Component;
import org.springframework.ai.chat.client.ChatClient;
/**
* Handler for MCP sampling requests.
* Integrates with Spring AI chat client to generate responses.
* Thread-safe - chat client handles concurrency internally.
*/
@Component
public class McpSamplingHandler {
private final ChatClient chatClient;
public McpSamplingHandler(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
/**
* Handle sampling requests from MCP servers.
* Called when servers need AI completion/generation.
*
* @param connectionName Name of the connection requesting sampling
* @param request Sampling request with messages and preferences
* @return Generated message result
*/
@McpSampling
public McpSchema.CreateMessageResult handleSampling(
String connectionName,
McpSchema.CreateMessageRequest request) {
// Convert MCP messages to Spring AI format
String prompt = convertMessagesToPrompt(request.messages());
// Generate response using Spring AI
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
// Convert back to MCP format
return new McpSchema.CreateMessageResult(
McpSchema.Role.ASSISTANT,
new McpSchema.TextContent(response),
null, // model (optional)
McpSchema.StopReason.END_TURN
);
}
private String convertMessagesToPrompt(List<McpSchema.SamplingMessage> messages) {
return messages.stream()
.map(msg -> {
if (msg.content() instanceof McpSchema.TextContent textContent) {
return textContent.text();
}
return "";
})
.collect(Collectors.joining("\n"));
}
}ClientMcpSyncHandlersRegistry or ClientMcpAsyncHandlersRegistry)Timing: During Spring context refresh, after bean instantiation but before bean post-processing completes
Order:
Synchronous Handlers:
Asynchronous Handlers:
The handler registries are automatically injected into MCP client beans. You don't need to manually wire handlers to clients - the auto-configuration handles this:
// Automatic integration - no manual wiring needed
import io.modelcontextprotocol.client.McpSyncClient;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class McpService {
private final List<McpSyncClient> mcpClients;
/**
* Constructor injection of MCP clients.
* Clients already have all annotated handlers registered automatically.
*
* @param mcpClients Auto-configured MCP clients with handlers
*/
public McpService(List<McpSyncClient> mcpClients) {
// Clients already have annotated handlers registered
this.mcpClients = mcpClients;
}
// Use clients directly - handlers are already configured
}The annotation scanner includes AOT (Ahead-of-Time) processing support for GraalVM native image compilation. All MCP annotations are automatically registered for reflection during AOT processing.
The auto-configuration includes a ClientAnnotatedBeanFactoryInitializationAotProcessor that processes annotated beans during AOT compilation:
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
/**
* AOT processor that ensures annotation-based handlers work in native images.
* Automatically registers all MCP annotations and handler beans for reflection.
* Runs during Spring AOT processing phase (build time).
* Generates reflection hints and proxy configurations.
*/
@org.springframework.context.annotation.Bean
public static ClientAnnotatedBeanFactoryInitializationAotProcessor clientAnnotatedBeanFactoryInitializationAotProcessor();The processor automatically:
No additional configuration is required - native image compilation works automatically when using this starter.
To build a native image with MCP annotation handlers:
./mvnw -Pnative native:compile
# OR for Gradle
./gradlew nativeCompileNative image features:
@Component, @Service, etc.)System.out in productionDon't Block in Async Handlers:
// BAD - blocks reactive chain
@McpProgress
public Mono<Void> handleProgress(String conn, long curr, long total) {
Thread.sleep(1000); // Don't block!
return Mono.empty();
}
// GOOD - use reactive delay
@McpProgress
public Mono<Void> handleProgress(String conn, long curr, long total) {
return Mono.delay(Duration.ofSeconds(1)).then();
}Don't Throw Unchecked Exceptions Carelessly:
// BAD - crashes MCP client
@McpLogging
public void handleLogging(String conn, String level, String msg) {
throw new RuntimeException("Oops!"); // Will break MCP connection!
}
// GOOD - handle errors gracefully
@McpLogging
public void handleLogging(String conn, String level, String msg) {
try {
// ... handler logic ...
} catch (Exception e) {
log.error("Error in logging handler", e);
// Don't propagate exception
}
}Don't Access External Resources Without Timeouts:
// BAD - may hang indefinitely
@McpToolListChanged
public void handleTools(String conn, List<Tool> tools) {
httpClient.get("http://slow-server/update"); // No timeout!
}
// GOOD - use timeouts
@McpToolListChanged
public void handleTools(String conn, List<Tool> tools) {
try {
httpClient.get("http://slow-server/update")
.timeout(Duration.ofSeconds(5));
} catch (TimeoutException e) {
log.warn("Update notification timed out", e);
}
}If you need to conditionally enable/disable specific handlers, use Spring's @ConditionalOnProperty:
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* Optional logging handler that can be disabled via configuration.
* Enabled only when myapp.mcp.logging.enabled=true
*/
@Component
@ConditionalOnProperty(name = "myapp.mcp.logging.enabled", havingValue = "true")
public class OptionalLoggingHandler {
@McpLogging
public void handleLogging(String connectionName, String level, String message) {
// This handler only active if myapp.mcp.logging.enabled=true
System.out.printf("[%s] %s: %s%n", connectionName, level, message);
}
}If handlers are not being called:
org.springaicommunity.mcp.annotation)@Component)spring.ai.mcp.client.annotation-scanner.enabled=truespring-ai-mcp-annotations is on classpathIf you see warnings about invalid signatures:
If handlers throw exceptions:
If handlers are slow:
tessl i tessl/maven-org-springframework-ai--spring-ai-starter-mcp-client-webflux@1.1.0