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
Practical examples demonstrating common use cases for Spring AI MCP Client WebFlux.
Build an AI assistant that uses tools from multiple MCP servers.
spring.ai.mcp.client:
type: SYNC
request-timeout: 30s
sse:
connections:
weather-service:
url: http://localhost:8080
database-service:
url: http://localhost:9000
calendar-service:
url: http://localhost:9001import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class AssistantService {
private final ChatClient chatClient;
public AssistantService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String handleQuery(String userQuery) {
// AI automatically discovers and uses tools from all three servers
return chatClient.prompt()
.user(userQuery)
.call()
.content();
}
}// Query that uses multiple tools
String result = assistantService.handleQuery(
"What's the weather in San Francisco, " +
"find meetings today, " +
"and check database for users from that city"
);
// AI will automatically:
// 1. Call weather tool via weather-service
// 2. Call calendar tool via calendar-service
// 3. Call database query tool via database-service
// 4. Synthesize results into responseUse local MCP tool servers for development environment.
spring.ai.mcp.client:
stdio:
connections:
filesystem-tools:
command: npx
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- ${user.home}/projects
git-tools:
command: npx
args:
- -y
- "@modelcontextprotocol/server-git"
- ${user.home}/projects
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/id_rsaimport io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.stereotype.Service;
@Service
public class DevToolsService {
private final List<McpSyncClient> clients;
public DevToolsService(List<McpSyncClient> clients) {
this.clients = clients;
}
public String listFiles(String directory) {
return executeToolOnServer("filesystem-tools", "list_directory",
Map.of("path", directory));
}
public String readFile(String path) {
return executeToolOnServer("filesystem-tools", "read_file",
Map.of("path", path));
}
public String gitStatus() {
return executeToolOnServer("git-tools", "git_status", Map.of());
}
private String executeToolOnServer(String serverPattern, String toolName,
Map<String, Object> args) {
for (McpSyncClient client : clients) {
String clientName = client.getClientInfo().name();
if (clientName.contains(serverPattern)) {
var request = McpSchema.CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
var result = client.callTool(request);
return result.content().toString();
}
}
throw new IllegalStateException("Server not found: " + serverPattern);
}
}Secure production deployment with tool filtering and monitoring.
spring.ai.mcp.client:
type: ASYNC
request-timeout: 15s
streamable-http:
connections:
production-api:
url: https://api.production.com
endpoint: /mcp/v1import org.springframework.ai.mcp.McpToolFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SecurityConfig {
@Bean
public McpToolFilter productionToolFilter() {
return (connectionInfo, tool) -> {
String toolName = tool.name();
// Block destructive operations
if (toolName.matches("(delete|drop|truncate|destroy).*")) {
return false;
}
// Block file system access
if (toolName.startsWith("file_") || toolName.startsWith("fs_")) {
return false;
}
// Only from trusted servers
String serverName = connectionInfo.initializeResult()
.serverInfo().name();
if (!serverName.contains("production")) {
return false;
}
// Require description (quality check)
return tool.description() != null && !tool.description().isEmpty();
};
}
}import org.springframework.ai.mcp.McpToolsChangedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.MeterRegistry;
@Component
public class McpMonitoring {
private final MeterRegistry meterRegistry;
public McpMonitoring(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Async
@EventListener
public void monitorToolChanges(McpToolsChangedEvent event) {
// Record metrics
meterRegistry.gauge("mcp.tools.count",
List.of(Tag.of("connection", event.getConnectionName())),
event.getTools().size());
// Alert if no tools
if (event.getTools().isEmpty()) {
sendAlert("No tools available: " + event.getConnectionName());
}
}
private void sendAlert(String message) {
// Alert implementation
}
}Different configurations for dev, test, and production.
spring.ai.mcp.client:
type: SYNC
request-timeout: 2m # Long timeout for debugging
stdio:
connections:
local-dev-server:
command: npm
args:
- run
- dev-server
env:
DEBUG: "true"
LOG_LEVEL: DEBUGspring.ai.mcp.client:
type: SYNC
request-timeout: 30s
sse:
connections:
test-server:
url: http://test-mcp-server:8080spring.ai.mcp.client:
type: ASYNC # Use async for production scale
request-timeout: 10s # Fail fast
streamable-http:
connections:
production-api:
url: https://api.production.com
endpoint: /mcp/v1import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
public class EnvironmentConfig {
@Bean
@Profile("dev")
public McpSyncClientCustomizer devCustomizer() {
return (name, spec) -> {
spec.loggingConsumer(log ->
System.out.println("[DEV] " + log.data())
);
};
}
@Bean
@Profile("prod")
public McpSyncClientCustomizer prodCustomizer() {
return (name, spec) -> {
// Production settings - minimal logging
spec.requestTimeout(Duration.ofSeconds(10));
};
}
}Optimize performance with caching and parallel execution.
import org.springframework.ai.mcp.McpToolsChangedEvent;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@Service
public class CachedToolService {
@Cacheable("mcp-tools")
public List<Tool> getTools(String connectionName) {
// Tools cached - only fetched once
return clients.stream()
.filter(c -> c.getClientInfo().name().contains(connectionName))
.findFirst()
.map(McpSyncClient::listTools)
.orElse(List.of());
}
@CacheEvict(value = "mcp-tools", allEntries = true)
@EventListener
public void evictCache(McpToolsChangedEvent event) {
// Invalidate cache when tools change
}
}import org.springframework.ai.mcp.AsyncMcpToolCallbackProvider;
import reactor.core.publisher.Flux;
@Service
public class ParallelExecutionService {
private final List<McpAsyncClient> clients;
public ParallelExecutionService(List<McpAsyncClient> clients) {
this.clients = clients;
}
public Map<String, Object> executeToolsInParallel(
Map<String, Map<String, Object>> toolRequests) {
return Flux.fromIterable(toolRequests.entrySet())
.flatMap(entry -> {
String toolName = entry.getKey();
Map<String, Object> args = entry.getValue();
return findAndExecuteTool(toolName, args)
.map(result -> Map.entry(toolName, result));
})
.collectMap(Map.Entry::getKey, Map.Entry::getValue)
.block();
}
private Mono<Object> findAndExecuteTool(String toolName,
Map<String, Object> args) {
// Find and execute tool across all clients
return Flux.fromIterable(clients)
.flatMap(client ->
client.listTools()
.flatMapMany(Flux::fromIterable)
.filter(tool -> tool.name().equals(toolName))
.next()
.flatMap(tool -> {
var request = CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
return client.callTool(request)
.map(CallToolResult::content);
})
)
.next();
}
}Configure WebClient for corporate environment with proxy and authentication.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import reactor.netty.transport.ProxyProvider;
@Configuration
public class ProxyConfig {
@Bean
public WebClient.Builder webClientBuilder() {
HttpClient httpClient = HttpClient.create()
.proxy(proxy -> proxy
.type(ProxyProvider.Proxy.HTTP)
.host("proxy.corporate.com")
.port(8080)
.username("user")
.password(s -> getProxyPassword())
);
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeader("User-Agent", "CorporateApp-MCP/1.0");
}
private String getProxyPassword() {
// Load from secure source
return System.getenv("PROXY_PASSWORD");
}
}Discover MCP servers dynamically from service registry.
import org.springframework.ai.mcp.client.common.autoconfigure.McpSseClientConnectionDetails;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DynamicDiscoveryConfig {
private final DiscoveryClient discoveryClient;
public DynamicDiscoveryConfig(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
@Bean
public McpSseClientConnectionDetails dynamicConnectionDetails() {
return () -> {
Map<String, SseParameters> connections = new HashMap<>();
// Discover MCP services from registry
List<ServiceInstance> instances =
discoveryClient.getInstances("mcp-service");
for (ServiceInstance instance : instances) {
String connectionName = instance.getInstanceId();
String url = instance.getUri().toString();
connections.put(connectionName,
new SseParameters(url, "/sse"));
}
return connections;
};
}
}Transform MCP tool results before returning to AI.
import org.springframework.ai.mcp.ToolContextToMcpMetaConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ToolTransformationConfig {
@Bean
public ToolContextToMcpMetaConverter metadataConverter() {
return toolContext -> {
Map<String, Object> metadata = new HashMap<>();
// Add request tracking
metadata.put("requestId", UUID.randomUUID().toString());
metadata.put("timestamp", System.currentTimeMillis());
// Add user context
String userId = (String) toolContext.getContext().get("userId");
metadata.put("userId", userId);
// Add application version
metadata.put("appVersion", "1.0.0");
return metadata;
};
}
}Handle server failures gracefully with fallbacks.
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
@Service
public class ResilientMcpService {
private final List<McpSyncClient> clients;
public ResilientMcpService(List<McpSyncClient> clients) {
this.clients = clients;
}
@CircuitBreaker(name = "mcp-tools", fallbackMethod = "fallbackExecute")
@Retry(name = "mcp-tools")
public Object executeTool(String toolName, Map<String, Object> args) {
for (McpSyncClient client : clients) {
try {
List<Tool> tools = client.listTools();
if (tools.stream().anyMatch(t -> t.name().equals(toolName))) {
var request = CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
return client.callTool(request).content();
}
} catch (Exception e) {
log.warn("Failed to execute on client: {}",
client.getClientInfo().name(), e);
// Continue to next client
}
}
throw new IllegalStateException("Tool not found or all servers failed");
}
private Object fallbackExecute(String toolName, Map<String, Object> args,
Throwable throwable) {
log.error("All attempts failed for tool: {}", toolName, throwable);
return Map.of("error", "Service temporarily unavailable",
"toolName", toolName);
}
}Track tool usage for analytics and debugging.
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Component
public class ToolUsageTracker {
private final Map<String, AtomicLong> usageCount = new ConcurrentHashMap<>();
private final Map<String, List<ToolExecution>> executionHistory =
new ConcurrentHashMap<>();
public void trackExecution(String toolName, Map<String, Object> args,
Object result, long durationMs) {
// Increment usage count
usageCount.computeIfAbsent(toolName, k -> new AtomicLong())
.incrementAndGet();
// Record execution
ToolExecution execution = new ToolExecution(
toolName,
args,
result,
durationMs,
System.currentTimeMillis()
);
executionHistory.computeIfAbsent(toolName, k -> new ArrayList<>())
.add(execution);
}
public Map<String, Long> getUsageStats() {
return usageCount.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().get()
));
}
public record ToolExecution(
String toolName,
Map<String, Object> args,
Object result,
long durationMs,
long timestamp
) {}
}import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class ToolExecutionAspect {
private final ToolUsageTracker tracker;
public ToolExecutionAspect(ToolUsageTracker tracker) {
this.tracker = tracker;
}
@Around("execution(* io.modelcontextprotocol.client.McpSyncClient.callTool(..))")
public Object trackToolExecution(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
CallToolRequest request = (CallToolRequest) joinPoint.getArgs()[0];
try {
CallToolResult result = (CallToolResult) joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
tracker.trackExecution(
request.name(),
request.arguments(),
result.content(),
duration
);
return result;
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
tracker.trackExecution(request.name(), request.arguments(),
"ERROR: " + e.getMessage(), duration);
throw e;
}
}
}Implement intelligent tool naming for multi-server scenarios.
import org.springframework.ai.mcp.McpToolNamePrefixGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ToolNamingConfig {
@Bean
public McpToolNamePrefixGenerator smartPrefixGenerator() {
return (connectionInfo, tool) -> {
String serverName = connectionInfo.initializeResult()
.serverInfo().name();
String toolName = tool.name();
// For common tools, add server prefix
if (isCommonToolName(toolName)) {
return sanitize(serverName) + "_" + toolName;
}
// For unique tools, keep original name
return toolName;
};
}
private boolean isCommonToolName(String name) {
Set<String> common = Set.of("search", "query", "list", "get", "fetch");
return common.stream().anyMatch(name::startsWith);
}
private String sanitize(String name) {
return name.toLowerCase().replaceAll("[^a-z0-9]", "_");
}
}Access MCP resources with intelligent caching.
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ResourceService {
private final List<McpSyncClient> clients;
public ResourceService(List<McpSyncClient> clients) {
this.clients = clients;
}
@Cacheable(value = "mcp-resources", key = "#uri")
public String readResource(String uri) {
for (McpSyncClient client : clients) {
try {
var request = ReadResourceRequest.builder()
.uri(uri)
.build();
var result = client.readResource(request);
return extractContent(result.contents());
} catch (Exception e) {
log.debug("Failed to read resource from client", e);
}
}
throw new ResourceNotFoundException("Resource not found: " + uri);
}
public List<Resource> listAllResources() {
return clients.stream()
.flatMap(client -> {
try {
return client.listResources().stream();
} catch (Exception e) {
log.error("Failed to list resources", e);
return Stream.empty();
}
})
.toList();
}
private String extractContent(Object contents) {
// Extract text content from various formats
if (contents instanceof String text) {
return text;
} else if (contents instanceof List<?> list) {
return list.stream()
.map(Object::toString)
.collect(Collectors.joining("\n"));
}
return contents.toString();
}
}Use annotations for clean integration.
import org.springaicommunity.mcp.annotation.*;
import org.springframework.stereotype.Component;
@Component
public class McpHandlers {
@McpLogging
public void handleLogging(String connection, String level, String message) {
log.info("[{}] {}: {}", connection, level, message);
}
@McpProgress
public void handleProgress(String connection, long current, long total) {
double pct = (current * 100.0) / total;
log.info("Progress on {}: {:.2f}%", connection, pct);
}
@McpToolListChanged
public void handleToolChange(String connection, List<Tool> tools) {
log.info("Tools updated on {}: {} tools", connection, tools.size());
refreshToolCache(connection, tools);
}
@McpSampling
public CreateMessageResult handleSampling(String connection,
CreateMessageRequest request) {
// Delegate to AI service
String response = aiService.generate(
convertMessages(request.messages())
);
return new CreateMessageResult(
Role.ASSISTANT,
new TextContent(response),
null,
StopReason.END_TURN
);
}
private void refreshToolCache(String connection, List<Tool> tools) {
// Cache update logic
}
private String convertMessages(List<SamplingMessage> messages) {
// Message conversion logic
return messages.stream()
.map(this::extractText)
.collect(Collectors.joining("\n"));
}
private String extractText(SamplingMessage message) {
if (message.content() instanceof TextContent text) {
return text.text();
}
return "";
}
}tessl i tessl/maven-org-springframework-ai--spring-ai-starter-mcp-client-webflux@1.1.0