Spring Framework integration for Model Context Protocol (MCP), providing Spring AI function calling capabilities and Spring-friendly abstractions for MCP clients and MCP servers
This document covers edge cases, corner scenarios, and advanced usage patterns that coding agents should handle correctly.
Scenario: MCP server returns no tools.
McpSyncClient client = // ... initialize client
McpSchema.ListToolsResult result = client.listTools();
if (result.tools().isEmpty()) {
System.out.println("No tools available from MCP server");
// Handle gracefully - don't fail
}
SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder()
.mcpClients(client)
.build();
ToolCallback[] callbacks = provider.getToolCallbacks();
// callbacks.length == 0Scenario: Tool called with null or empty input.
SyncMcpToolCallback callback = // ... create callback
// All of these become "{}"
String result1 = callback.call(null);
String result2 = callback.call("");
String result3 = callback.call(" ");
// Equivalent to:
String result = callback.call("{}");Scenario: MCP server returns empty result.
String result = callback.call("{}");
// Result could be any of:
// "", "{}", "[]", "null", "{\"result\": null}"
// Always validate before parsing
if (result != null && !result.isEmpty() && !result.equals("null")) {
// Process result
JsonNode node = objectMapper.readTree(result);
} else {
// Handle empty result
System.out.println("Tool returned empty result");
}Scenario: Sending large JSON payloads to MCP tools.
// Generate large input (e.g., 10MB JSON)
Map<String, Object> largeData = generateLargeDataset();
String largeInput = objectMapper.writeValueAsString(largeData);
// Risk: May timeout or cause OutOfMemoryError
try {
String result = callback.call(largeInput);
} catch (ToolExecutionException e) {
if (e.getCause() instanceof OutOfMemoryError) {
System.err.println("Input too large for MCP tool");
// Consider chunking the data
} else if (e.getCause() instanceof SocketTimeoutException) {
System.err.println("Tool execution timed out");
// Increase timeout or chunk data
}
}Solution: Chunk large payloads:
public String processLargeDataInChunks(SyncMcpToolCallback callback,
List<Map<String, Object>> chunks) {
List<String> results = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
String chunkInput = objectMapper.writeValueAsString(Map.of(
"chunk", i,
"total", chunks.size(),
"data", chunks.get(i)
));
try {
String result = callback.call(chunkInput);
results.add(result);
} catch (ToolExecutionException e) {
System.err.println("Chunk " + i + " failed: " + e.getMessage());
// Continue or fail based on requirements
}
}
// Aggregate results
return aggregateResults(results);
}Scenario: MCP tool returns large response.
AsyncMcpToolCallback callback = // ... async callback for large operations
// Use async for better resource utilization
String largeResult = callback.call(input);
// Stream processing if result is very large
try (JsonParser parser = jsonFactory.createParser(largeResult)) {
while (parser.nextToken() != null) {
// Process incrementally
processToken(parser);
}
}Scenario: Different tools require different timeouts.
// Configure timeout at MCP client level
McpSyncClient quickClient = McpClient.sync()
.timeout(Duration.ofSeconds(5)) // 5-second timeout
.build();
McpSyncClient slowClient = McpClient.sync()
.timeout(Duration.ofSeconds(60)) // 60-second timeout
.build();
// Use appropriate client based on tool characteristics
SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder()
.mcpClients(quickClient, slowClient)
.toolFilter((info, tool) -> {
String clientName = info.clientInfo().name();
// Route long-running tools to slowClient
if (tool.description().contains("long-running")) {
return clientName.equals("slow-client");
}
return clientName.equals("quick-client");
})
.build();Scenario: Timeout for async operations with graceful degradation.
import reactor.core.publisher.Mono;
import java.time.Duration;
public String executeWithTimeout(AsyncMcpToolCallback callback,
String input,
Duration timeout,
String fallback) {
return Mono.defer(() -> {
try {
return Mono.just(callback.call(input));
} catch (ToolExecutionException e) {
return Mono.error(e);
}
})
.timeout(timeout)
.onErrorReturn(fallback)
.block();
}
// Usage
String result = executeWithTimeout(
callback,
input,
Duration.ofSeconds(10),
"{\"error\": \"timeout\", \"fallback\": true}"
);Scenario: Cancel long-running async tool execution.
import reactor.core.Disposable;
Mono<String> toolExecution = Mono.defer(() -> {
try {
return Mono.just(callback.call(input));
} catch (ToolExecutionException e) {
return Mono.error(e);
}
})
.doOnCancel(() -> System.out.println("Tool execution cancelled"));
Disposable subscription = toolExecution.subscribe(
result -> System.out.println("Result: " + result),
error -> System.err.println("Error: " + error)
);
// Cancel after 5 seconds if not complete
Thread.sleep(5000);
subscription.dispose();Scenario: Multiple threads invalidating cache simultaneously.
SyncMcpToolCallbackProvider provider = // ... create provider
// Thread-safe cache invalidation
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
provider.invalidateCache(); // Thread-safe
ToolCallback[] callbacks = provider.getToolCallbacks(); // Thread-safe
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);Scenario: Execute same tool concurrently with different inputs.
SyncMcpToolCallback callback = // ... create once, reuse
ExecutorService executor = Executors.newFixedThreadPool(20);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
String input = "{\"id\": " + i + "}";
Future<String> future = executor.submit(() -> callback.call(input));
futures.add(future);
}
// Collect results
for (Future<String> future : futures) {
try {
String result = future.get();
// Process result
} catch (ExecutionException e) {
if (e.getCause() instanceof ToolExecutionException) {
// Handle tool error
}
}
}
executor.shutdown();Scenario: Execute multiple tools in parallel reactively.
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
List<AsyncMcpToolCallback> callbacks = // ... multiple callbacks
List<String> inputs = // ... corresponding inputs
Flux.range(0, callbacks.size())
.parallel(5) // 5 parallel threads
.runOn(Schedulers.parallel())
.map(i -> {
try {
return callbacks.get(i).call(inputs.get(i));
} catch (ToolExecutionException e) {
return "ERROR: " + e.getMessage();
}
})
.sequential()
.collectList()
.block(); // Get all resultsScenario: Temporary network issues causing intermittent failures.
public String executeWithTransientRetry(SyncMcpToolCallback callback,
String input,
int maxRetries) {
for (int attempt = 0; attempt < maxRetries; attempt++) {
try {
return callback.call(input);
} catch (ToolExecutionException e) {
Throwable cause = e.getCause();
// Retry only on transient errors
boolean isTransient =
cause instanceof java.net.ConnectException ||
cause instanceof java.net.SocketTimeoutException ||
cause instanceof java.net.SocketException;
if (!isTransient || attempt == maxRetries - 1) {
throw e; // Non-transient or final attempt
}
System.out.println("Transient error, retrying... " +
"Attempt " + (attempt + 1));
try {
// Exponential backoff
Thread.sleep((long) Math.pow(2, attempt) * 1000);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw e;
}
}
}
throw new IllegalStateException("Should not reach here");
}Scenario: All MCP client connections are busy.
// Configure MCP client with connection pooling
McpSyncClient client = McpClient.sync()
.maxConnections(50) // Limit concurrent connections
.connectionTimeout(Duration.ofSeconds(10))
.build();
// Monitor connection pool
public String executeWithPoolMonitoring(SyncMcpToolCallback callback,
String input) {
long startTime = System.currentTimeMillis();
try {
String result = callback.call(input);
long duration = System.currentTimeMillis() - startTime;
if (duration > 5000) {
System.out.println("Warning: Tool took " + duration +
"ms (possible pool contention)");
}
return result;
} catch (ToolExecutionException e) {
if (e.getCause() instanceof java.util.concurrent.TimeoutException) {
System.err.println("Connection pool exhausted or timeout");
// Consider increasing pool size or timeout
}
throw e;
}
}Scenario: Tool called with incomplete input that's still valid JSON.
// Tool expects: {"name": string, "age": number}
// But receives: {"name": "John"}
String incompleteInput = "{\"name\": \"John\"}";
try {
String result = callback.call(incompleteInput);
// MCP server should return error
} catch (ToolExecutionException e) {
// Expected: server validates schema
System.err.println("Schema validation failed: " + e.getMessage());
}Solution: Pre-validate on client side:
import com.networknt.schema.*;
public void validateAndCall(SyncMcpToolCallback callback, String input) {
// Get schema from tool definition
ToolDefinition def = callback.getToolDefinition();
String schemaJson = def.inputSchema();
// Create validator
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
JsonSchema schema = factory.getSchema(schemaJson);
// Validate
ObjectMapper mapper = new ObjectMapper();
JsonNode inputNode = mapper.readTree(input);
Set<ValidationMessage> errors = schema.validate(inputNode);
if (!errors.isEmpty()) {
throw new IllegalArgumentException("Invalid input: " + errors);
}
// Input is valid
String result = callback.call(input);
}Scenario: Input contains fields not in schema.
// Schema expects: {"name": string}
// Input has: {"name": "John", "unknown": "value"}
String inputWithExtra = "{\"name\": \"John\", \"unknown\": \"value\"}";
// Most MCP servers will ignore extra fields
String result = callback.call(inputWithExtra);
// But some strict servers may reject
try {
result = callback.call(inputWithExtra);
} catch (ToolExecutionException e) {
System.err.println("Server rejected extra fields");
// Strip unknown fields and retry
String cleanInput = removeUnknownFields(inputWithExtra, callback.getToolDefinition());
result = callback.call(cleanInput);
}Scenario: Multiple servers expose tools with the same name.
McpSyncClient server1 = // ... has tool "list_files"
McpSyncClient server2 = // ... also has tool "list_files"
// Without prefixing - FAILS with IllegalStateException
try {
SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder()
.mcpClients(server1, server2)
.toolNamePrefixGenerator(McpToolNamePrefixGenerator.noPrefix())
.build();
provider.getToolCallbacks(); // Throws IllegalStateException
} catch (IllegalStateException e) {
System.err.println("Duplicate tool names: " + e.getMessage());
}
// Solution: Use prefixing
SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder()
.mcpClients(server1, server2)
.toolNamePrefixGenerator(new DefaultMcpToolNamePrefixGenerator())
.build();
// Tools become: "s_1_list_files", "s_2_list_files"
ToolCallback[] callbacks = provider.getToolCallbacks();Scenario: Implement custom naming strategy to avoid collisions.
McpToolNamePrefixGenerator customGenerator = (connectionInfo, tool) -> {
String serverName = connectionInfo.clientInfo().name();
String environment = System.getenv("ENVIRONMENT");
String version = connectionInfo.clientInfo().version().split("\\.")[0];
// Format: env_server_v1_toolname
return String.format("%s_%s_v%s_%s",
environment.toLowerCase(),
serverName.replaceAll("[^a-z0-9]", "_"),
version,
tool.name()
);
};
SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder()
.mcpClients(server1, server2, server3)
.toolNamePrefixGenerator(customGenerator)
.build();
// Example names:
// "prod_weather_v1_get_forecast"
// "prod_database_v2_query_users"Scenario: MCP server adds new tools while application is running.
SyncMcpToolCallbackProvider provider = // ... create provider
// Initial tools
ToolCallback[] initial = provider.getToolCallbacks();
System.out.println("Initial tools: " + initial.length);
// Server adds new tools...
// Option 1: Manual refresh
provider.invalidateCache();
ToolCallback[] updated = provider.getToolCallbacks();
System.out.println("Updated tools: " + updated.length);
// Option 2: Event-driven refresh
@EventListener
public void onToolsChanged(McpToolsChangedEvent event) {
// Provider automatically invalidates cache
// Next getToolCallbacks() will have new tools
System.out.println("Tools changed for: " + event.getConnectionName());
}Scenario: Tool exists when discovered but removed before execution.
ToolCallback callback = provider.getToolCallbacks()[0];
// Server removes this tool...
try {
String result = callback.call(input);
} catch (ToolExecutionException e) {
System.err.println("Tool no longer exists on server");
// Refresh and retry with updated tools
provider.invalidateCache();
ToolCallback[] updated = provider.getToolCallbacks();
// Find alternative or handle gracefully
Optional<ToolCallback> alternative = Arrays.stream(updated)
.filter(cb -> cb.getToolDefinition().name().contains("alternative"))
.findFirst();
if (alternative.isPresent()) {
result = alternative.get().call(input);
}
}Scenario: MCP tools with Unicode characters in names.
// Tool with Chinese characters: "查询_用户"
McpSchema.Tool tool = // ... tool with Unicode name
SyncMcpToolCallback callback = SyncMcpToolCallback.builder()
.mcpClient(client)
.tool(tool)
.build();
// McpToolUtils.format() preserves Han script and CJK characters
String formatted = McpToolUtils.format("查询_用户");
// Result: "查询_用户" (preserved)
// Works correctly
String result = callback.call(input);Scenario: JSON input contains special characters requiring escaping.
String input = "{"
+ "\"name\": \"John\\\"The King\\\"Smith\","
+ "\"description\": \"Line1\\nLine2\","
+ "\"path\": \"C:\\\\Users\\\\John\""
+ "}";
// Proper escaping maintained
String result = callback.call(input);
// Or use ObjectMapper for safety
Map<String, Object> data = Map.of(
"name", "John\"The King\"Smith",
"description", "Line1\nLine2",
"path", "C:\\Users\\John"
);
String safeInput = objectMapper.writeValueAsString(data);
String result = callback.call(safeInput);Scenario: Ensure callbacks don't cause memory leaks in long-running applications.
@Service
public class ToolCacheManager {
private final Map<String, WeakReference<ToolCallback[]>> toolCache =
new ConcurrentHashMap<>();
public ToolCallback[] getTools(String serverKey,
SyncMcpToolCallbackProvider provider) {
WeakReference<ToolCallback[]> ref = toolCache.get(serverKey);
if (ref == null || ref.get() == null) {
// Cache miss - refresh
ToolCallback[] callbacks = provider.getToolCallbacks();
toolCache.put(serverKey, new WeakReference<>(callbacks));
return callbacks;
}
return ref.get();
}
@Scheduled(fixedDelay = 300000) // Every 5 minutes
public void cleanupCache() {
toolCache.entrySet().removeIf(entry ->
entry.getValue().get() == null
);
}
}Scenario: Properly close MCP clients on application shutdown.
@Configuration
public class McpClientConfiguration {
@Bean(destroyMethod = "close")
public McpSyncClient mcpClient() {
return McpClient.sync()
.serverInfo(/* ... */)
.build();
}
@PreDestroy
public void cleanup() {
System.out.println("Cleaning up MCP resources");
}
}
// Or manual cleanup
@Component
public class McpClientManager implements DisposableBean {
private final List<McpSyncClient> clients;
@Override
public void destroy() throws Exception {
for (McpSyncClient client : clients) {
try {
client.close();
} catch (Exception e) {
System.err.println("Error closing client: " + e.getMessage());
}
}
}
}