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
Advanced usage patterns, corner cases, and solutions for complex requirements.
When multiple servers provide tools with the same name.
spring.ai.mcp.client:
sse:
connections:
server-a:
url: http://localhost:8080 # Provides tool "search"
server-b:
url: http://localhost:9000 # Also provides tool "search"@Bean
public McpToolNamePrefixGenerator uniqueNamingStrategy() {
Map<String, Integer> nameCounters = new ConcurrentHashMap<>();
return (connectionInfo, tool) -> {
String originalName = tool.name();
String serverName = connectionInfo.initializeResult()
.serverInfo().name();
// Track how many times we've seen this tool name
int count = nameCounters.merge(originalName, 1, Integer::sum);
if (count == 1) {
// First occurrence - use original name
return originalName;
} else {
// Duplicate - prefix with server name
return sanitize(serverName) + "_" + originalName;
}
};
}Handle scenarios where MCP servers aren't available when Spring starts.
spring.ai.mcp.client:
initialized: false # Don't auto-initialize
sse:
connections:
optional-server:
url: http://localhost:8080@Service
public class LazyMcpService {
private final List<McpSyncClient> clients;
public LazyMcpService(List<McpSyncClient> clients) {
this.clients = clients;
}
public void ensureInitialized(McpSyncClient client) {
if (!client.isInitialized()) {
try {
client.initialize();
log.info("Initialized client: {}", client.getClientInfo().name());
} catch (Exception e) {
log.error("Failed to initialize client", e);
// Handle failure - maybe schedule retry
scheduleRetry(client);
}
}
}
private void scheduleRetry(McpSyncClient client) {
// Implement retry logic with exponential backoff
}
}Handle tools that return large amounts of data.
@Bean
public WebClient.Builder largePayloadWebClientBuilder() {
return WebClient.builder()
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(100 * 1024 * 1024)); // 100MB buffer
}@Service
public class LargeResultHandler {
public void handleLargeResult(String toolName, Map<String, Object> args) {
// For async clients with large results
asyncClient.callTool(request)
.doOnNext(result -> {
// Process in chunks
processInChunks(result.content());
})
.subscribe();
}
private void processInChunks(Object content) {
// Chunk processing logic
if (content instanceof String large) {
int chunkSize = 1024 * 1024; // 1MB chunks
for (int i = 0; i < large.length(); i += chunkSize) {
String chunk = large.substring(i,
Math.min(i + chunkSize, large.length()));
processChunk(chunk);
}
}
}
private void processChunk(String chunk) {
// Process each chunk
}
}Validate tool arguments before execution.
import com.fasterxml.jackson.databind.JsonNode;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
@Service
public class ToolValidator {
private final JsonSchemaFactory schemaFactory =
JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
public void validateAndExecute(McpSyncClient client, String toolName,
Map<String, Object> args) {
// Get tool schema
Tool tool = client.listTools().stream()
.filter(t -> t.name().equals(toolName))
.findFirst()
.orElseThrow();
// Validate arguments against schema
Set<ValidationMessage> errors = validateArguments(tool.inputSchema(), args);
if (!errors.isEmpty()) {
throw new IllegalArgumentException(
"Invalid arguments: " + errors.stream()
.map(ValidationMessage::getMessage)
.collect(Collectors.joining(", "))
);
}
// Execute tool
var request = CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
client.callTool(request);
}
private Set<ValidationMessage> validateArguments(Object schema,
Map<String, Object> args) {
try {
JsonSchema jsonSchema = schemaFactory.getSchema(
objectMapper.writeValueAsString(schema)
);
JsonNode argsNode = objectMapper.valueToTree(args);
return jsonSchema.validate(argsNode);
} catch (Exception e) {
log.error("Schema validation error", e);
return Set.of();
}
}
}Handle stdio process crashes and automatic recovery.
@Component
public class StdioProcessMonitor {
@Scheduled(fixedRate = 30000) // Check every 30 seconds
public void monitorProcesses() {
for (McpSyncClient client : clients) {
if (!isHealthy(client)) {
log.warn("Client unhealthy: {}", client.getClientInfo().name());
attemptRecovery(client);
}
}
}
private boolean isHealthy(McpSyncClient client) {
try {
// Simple health check - list tools
client.listTools();
return true;
} catch (Exception e) {
return false;
}
}
private void attemptRecovery(McpSyncClient client) {
try {
// Try to reinitialize
if (!client.isInitialized()) {
client.initialize();
log.info("Recovered client: {}", client.getClientInfo().name());
}
} catch (Exception e) {
log.error("Recovery failed", e);
sendAlert("MCP client recovery failed: " +
client.getClientInfo().name());
}
}
private void sendAlert(String message) {
// Alert implementation
}
}Different timeouts for different tools.
@Service
public class SmartTimeoutService {
private final Map<String, Duration> toolTimeouts = Map.of(
"heavy_computation", Duration.ofMinutes(5),
"database_query", Duration.ofSeconds(30),
"quick_lookup", Duration.ofSeconds(5)
);
public Object executeWithCustomTimeout(McpAsyncClient client,
String toolName,
Map<String, Object> args) {
Duration timeout = toolTimeouts.getOrDefault(toolName,
Duration.ofSeconds(20));
var request = CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
return client.callTool(request)
.timeout(timeout)
.onErrorResume(TimeoutException.class, e -> {
log.error("Tool {} timed out after {}", toolName, timeout);
return Mono.just(createErrorResult(toolName, "Timeout"));
})
.map(CallToolResult::content)
.block();
}
private CallToolResult createErrorResult(String toolName, String error) {
return CallToolResult.builder()
.content(Map.of("error", error, "tool", toolName))
.isError(true)
.build();
}
}Prevent overwhelming servers with too many concurrent requests.
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Refill;
@Service
public class RateLimitedToolService {
private final Map<String, Bucket> rateLimiters = new ConcurrentHashMap<>();
public Object executeWithRateLimit(McpSyncClient client,
String toolName,
Map<String, Object> args) {
String serverName = client.getCurrentInitializationResult()
.serverInfo().name();
// Get or create rate limiter for this server
Bucket bucket = rateLimiters.computeIfAbsent(serverName, k -> {
// Allow 10 requests per second
Bandwidth limit = Bandwidth.classic(10,
Refill.intervally(10, Duration.ofSeconds(1)));
return Bucket.builder().addLimit(limit).build();
});
// Wait for rate limit
bucket.asBlocking().consume(1);
// Execute tool
var request = CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
return client.callTool(request).content();
}
}Retry failed tool executions with exponential backoff.
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
@Configuration
public class RetryConfiguration {
@Bean
public RetryRegistry retryRegistry() {
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofSeconds(2))
.exponentialBackoffMultiplier(2.0)
.retryExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(IllegalArgumentException.class)
.build();
return RetryRegistry.of(config);
}
@Bean
public Retry toolRetry(RetryRegistry registry) {
return registry.retry("mcp-tools");
}
}
@Service
public class RetryableToolService {
private final Retry retry;
private final List<McpSyncClient> clients;
public RetryableToolService(Retry retry, List<McpSyncClient> clients) {
this.retry = retry;
this.clients = clients;
}
public Object executeWithRetry(String toolName, Map<String, Object> args) {
return Retry.decorateSupplier(retry, () ->
executeToolInternal(toolName, args)
).get();
}
private Object executeToolInternal(String toolName, Map<String, Object> args) {
// Tool execution logic
for (McpSyncClient client : clients) {
// ... find and execute tool
}
throw new IllegalStateException("Tool not found");
}
}Use both sync and async patterns in the same application (not recommended but sometimes necessary).
Can't configure both type: SYNC and type: ASYNC simultaneously.
@Configuration
public class MixedClientConfig {
// Use default auto-configured sync clients
// Then manually create async client for specific use case
@Bean
public McpAsyncClient manualAsyncClient(
List<NamedClientMcpTransport> transports) {
// Find specific transport
NamedClientMcpTransport transport = transports.stream()
.filter(t -> "special-server".equals(t.name()))
.findFirst()
.orElseThrow();
// Manually create async client
return McpClient.async(transport.transport())
.clientInfo(new Implementation("manual-async", "1.0.0"))
.requestTimeout(Duration.ofSeconds(30))
.build();
}
}Stream large tool results progressively.
@Service
public class StreamingToolService {
private final List<McpAsyncClient> clients;
public Flux<String> streamToolResult(String toolName,
Map<String, Object> args) {
return Flux.fromIterable(clients)
.flatMap(client ->
client.callTool(createRequest(toolName, args))
.flatMapMany(result -> {
// Split result into chunks
String content = result.content().toString();
return Flux.fromArray(content.split("\n"));
})
)
.take(1); // First successful result
}
private CallToolRequest createRequest(String toolName,
Map<String, Object> args) {
return CallToolRequest.builder()
.name(toolName)
.arguments(args)
.build();
}
}Different JSON configuration for different servers.
@Configuration
public class CustomTransportConfig {
@Bean
public List<NamedClientMcpTransport> customSseTransports(
McpSseClientConnectionDetails connectionDetails) {
List<NamedClientMcpTransport> transports = new ArrayList<>();
for (var entry : connectionDetails.getConnections().entrySet()) {
String name = entry.getKey();
SseParameters params = entry.getValue();
// Create connection-specific ObjectMapper
ObjectMapper mapper = createMapperForConnection(name);
// Create transport with custom mapper
var webClient = WebClient.builder()
.baseUrl(params.url())
.build();
var transport = WebFluxSseClientTransport.builder(webClient)
.sseEndpoint(params.sseEndpoint() != null ?
params.sseEndpoint() : "/sse")
.jsonMapper(new JacksonMcpJsonMapper(mapper))
.build();
transports.add(new NamedClientMcpTransport(name, transport));
}
return transports;
}
private ObjectMapper createMapperForConnection(String connectionName) {
ObjectMapper mapper = new ObjectMapper();
// Connection-specific configuration
if (connectionName.equals("strict-server")) {
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
} else {
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
return mapper;
}
}Tools should only be available in certain contexts.
@Component
public class ContextAwareToolFilter implements McpToolFilter {
private final SecurityContext securityContext;
public ContextAwareToolFilter(SecurityContext securityContext) {
this.securityContext = securityContext;
}
@Override
public boolean test(McpConnectionInfo connectionInfo, Tool tool) {
String toolName = tool.name();
// Admin-only tools
if (toolName.startsWith("admin_")) {
return securityContext.hasRole("ADMIN");
}
// Premium features
if (toolName.startsWith("premium_")) {
return securityContext.hasPremiumSubscription();
}
// Time-based restrictions
if (toolName.equals("batch_process")) {
return isOffPeakHours();
}
return true;
}
private boolean isOffPeakHours() {
int hour = LocalTime.now().getHour();
return hour < 6 || hour > 22;
}
}Pass dynamic environment variables to stdio processes.
@Bean
public McpStdioClientProperties dynamicStdioProperties() {
McpStdioClientProperties properties = new McpStdioClientProperties();
// Build dynamic environment
Map<String, String> env = new HashMap<>();
env.put("DATABASE_URL", getDatabaseUrl());
env.put("API_KEY", getApiKey());
env.put("LOG_LEVEL", getLogLevel());
env.put("TEMP_DIR", System.getProperty("java.io.tmpdir"));
Parameters params = new Parameters(
"node",
List.of("./mcp-server.js"),
env
);
properties.getConnections().put("dynamic-server", params);
return properties;
}
private String getDatabaseUrl() {
// Load from vault or config server
return vaultService.getSecret("database.url");
}
private String getApiKey() {
// Load from secure source
return secretManager.getApiKey();
}
private String getLogLevel() {
// Based on profile
return environment.getActiveProfiles()[0].equals("prod") ? "INFO" : "DEBUG";
}Cache expensive tool results with TTL.
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
@Service
public class CachedToolExecutionService {
private final Cache<CacheKey, Object> resultCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
private final List<McpSyncClient> clients;
public Object executeCached(String toolName, Map<String, Object> args) {
CacheKey key = new CacheKey(toolName, args);
return resultCache.get(key, k -> {
// Cache miss - execute tool
return executeToolInternal(toolName, args);
});
}
private Object executeToolInternal(String toolName, Map<String, Object> args) {
// Find and execute tool
for (McpSyncClient client : clients) {
// ... execution logic
}
throw new IllegalStateException("Tool not found");
}
@EventListener
public void invalidateOnToolChange(McpToolsChangedEvent event) {
// Invalidate cache when tools change
resultCache.invalidateAll();
}
private record CacheKey(String toolName, Map<String, Object> args) {}
}Handle SSE connection drops and reconnection logic.
@Service
public class SseHealthMonitor {
private final Map<String, ConnectionHealth> healthStatus =
new ConcurrentHashMap<>();
@Scheduled(fixedRate = 10000) // Check every 10 seconds
public void monitorHealth() {
for (McpSyncClient client : clients) {
String clientName = client.getClientInfo().name();
try {
// Lightweight health check
client.listTools();
recordHealthy(clientName);
} catch (Exception e) {
recordUnhealthy(clientName, e);
handleUnhealthyConnection(clientName, e);
}
}
}
private void recordHealthy(String clientName) {
healthStatus.compute(clientName, (k, v) -> {
if (v == null) {
return new ConnectionHealth(0, System.currentTimeMillis());
}
// Reset failure count on success
return new ConnectionHealth(0, System.currentTimeMillis());
});
}
private void recordUnhealthy(String clientName, Exception error) {
healthStatus.compute(clientName, (k, v) -> {
int failures = (v != null ? v.failureCount : 0) + 1;
return new ConnectionHealth(failures, System.currentTimeMillis());
});
}
private void handleUnhealthyConnection(String clientName, Exception error) {
ConnectionHealth health = healthStatus.get(clientName);
if (health != null && health.failureCount >= 3) {
log.error("Connection {} has failed {} times",
clientName, health.failureCount);
sendAlert("MCP connection unhealthy: " + clientName);
}
}
private record ConnectionHealth(int failureCount, long lastCheck) {}
private void sendAlert(String message) {
// Alert implementation
}
}Handle type mismatches between Spring AI and MCP.
@Component
public class ToolParameterConverter {
public Map<String, Object> convertParameters(
Tool tool,
Map<String, Object> springAiArgs) {
Map<String, Object> mcpArgs = new HashMap<>();
// Parse input schema
JsonNode schema = objectMapper.valueToTree(tool.inputSchema());
JsonNode properties = schema.get("properties");
if (properties != null) {
properties.fields().forEachRemaining(entry -> {
String paramName = entry.getKey();
JsonNode paramSchema = entry.getValue();
Object value = springAiArgs.get(paramName);
if (value != null) {
// Convert based on schema type
Object converted = convertValue(value, paramSchema);
mcpArgs.put(paramName, converted);
}
});
}
return mcpArgs;
}
private Object convertValue(Object value, JsonNode schema) {
String type = schema.get("type").asText();
return switch (type) {
case "integer" -> convertToInt(value);
case "number" -> convertToDouble(value);
case "boolean" -> convertToBoolean(value);
case "array" -> convertToList(value);
case "object" -> convertToMap(value);
default -> value.toString();
};
}
private Integer convertToInt(Object value) {
if (value instanceof Number num) return num.intValue();
return Integer.parseInt(value.toString());
}
private Double convertToDouble(Object value) {
if (value instanceof Number num) return num.doubleValue();
return Double.parseDouble(value.toString());
}
private Boolean convertToBoolean(Object value) {
if (value instanceof Boolean bool) return bool;
return Boolean.parseBoolean(value.toString());
}
private List<?> convertToList(Object value) {
if (value instanceof List<?> list) return list;
return List.of(value);
}
private Map<?, ?> convertToMap(Object value) {
if (value instanceof Map<?, ?> map) return map;
throw new IllegalArgumentException("Cannot convert to map: " + value);
}
}Custom certificate validation for self-signed certificates.
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
@Configuration
public class SslConfiguration {
@Bean
@Profile("dev") // Only for development!
public WebClient.Builder devWebClientBuilder() throws Exception {
// ONLY FOR DEVELOPMENT - accepts self-signed certificates
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}
};
SslContext sslContext = SslContextBuilder
.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
HttpClient httpClient = HttpClient.create()
.secure(spec -> spec.sslContext(sslContext));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient));
}
}Complete audit trail for all tool executions.
@Aspect
@Component
public class ToolAuditAspect {
private final AuditRepository auditRepository;
@Around("execution(* io.modelcontextprotocol.client.McpSyncClient.callTool(..))")
public Object auditToolExecution(ProceedingJoinPoint joinPoint) throws Throwable {
CallToolRequest request = (CallToolRequest) joinPoint.getArgs()[0];
McpSyncClient client = (McpSyncClient) joinPoint.getTarget();
String clientName = client.getClientInfo().name();
String toolName = request.name();
Map<String, Object> args = request.arguments();
AuditRecord audit = new AuditRecord();
audit.setClientName(clientName);
audit.setToolName(toolName);
audit.setArguments(objectMapper.writeValueAsString(args));
audit.setTimestamp(Instant.now());
audit.setUserId(getCurrentUserId());
try {
long startTime = System.currentTimeMillis();
CallToolResult result = (CallToolResult) joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
audit.setDuration(duration);
audit.setSuccess(true);
audit.setResult(objectMapper.writeValueAsString(result.content()));
return result;
} catch (Exception e) {
audit.setSuccess(false);
audit.setError(e.getMessage());
throw e;
} finally {
auditRepository.save(audit);
}
}
private String getCurrentUserId() {
// Get from security context
return SecurityContextHolder.getContext()
.getAuthentication()
.getName();
}
}Capture and log stdio process stderr.
// Note: This requires custom transport implementation
// Showing conceptual approach
@Component
public class StdioOutputHandler {
@EventListener
public void onContextRefresh(ContextRefreshedEvent event) {
// Find stdio transports and monitor their processes
List<NamedClientMcpTransport> transports =
applicationContext.getBean(
new ParameterizedTypeReference<List<NamedClientMcpTransport>>() {}
);
transports.stream()
.filter(t -> t.transport() instanceof StdioClientTransport)
.forEach(this::monitorProcess);
}
private void monitorProcess(NamedClientMcpTransport transport) {
// Access underlying process (if exposed)
// Read stderr asynchronously
new Thread(() -> {
// Pseudo-code - actual implementation depends on MCP SDK
BufferedReader reader = getProcessStderr(transport);
String line;
try {
while ((line = reader.readLine()) != null) {
log.info("[{}] {}", transport.name(), line);
}
} catch (IOException e) {
log.error("Error reading process output", e);
}
}).start();
}
}Different tool access based on tenant/user.
@Component
public class TenantAwareToolFilter implements McpToolFilter {
@Override
public boolean test(McpConnectionInfo connectionInfo, Tool tool) {
String tenantId = getCurrentTenantId();
String toolName = tool.name();
// Load tenant permissions
Set<String> allowedTools = getTenantAllowedTools(tenantId);
return allowedTools.contains(toolName);
}
private String getCurrentTenantId() {
// Get from security context or request scope
return TenantContext.getCurrentTenant();
}
private Set<String> getTenantAllowedTools(String tenantId) {
// Load from database or cache
return tenantPermissionRepository.getAllowedTools(tenantId);
}
}
// Request-scoped filter that changes per request
@Bean
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.INTERFACES)
public McpToolFilter requestScopedFilter() {
return new TenantAwareToolFilter();
}tessl i tessl/maven-org-springframework-ai--spring-ai-starter-mcp-client-webflux@1.1.0