CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-org-springframework-ai--spring-ai-mcp

Spring Framework integration for Model Context Protocol (MCP), providing Spring AI function calling capabilities and Spring-friendly abstractions for MCP clients and MCP servers

Overview
Eval results
Files

edge-cases.mddocs/examples/

Edge Cases and Advanced Scenarios

This document covers edge cases, corner scenarios, and advanced usage patterns that coding agents should handle correctly.

Empty and Null Handling

Empty Tool Lists

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 == 0

Null Input Handling

Scenario: 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("{}");

Empty Tool Results

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");
}

Large Payload Handling

Large Input Data

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);
}

Large Output Data

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);
    }
}

Timeout and Cancellation

Configurable Timeouts

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();

Reactive Timeout

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}"
);

Cancellation Handling

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();

Concurrency Edge Cases

Race Condition in Cache Invalidation

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);

Concurrent Tool Execution

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();

Reactive Parallel Execution

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 results

Network Failure Scenarios

Transient Connection Errors

Scenario: 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");
}

Connection Pool Exhaustion

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;
    }
}

Schema Validation Edge Cases

Missing Required Fields

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);
}

Extra Unknown Fields

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);
}

Tool Name Collisions

Duplicate Names Across Servers

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();

Custom Collision Resolution

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"

Dynamic Tool Changes

Tools Added During Runtime

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());
}

Tools Removed During Runtime

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);
    }
}

Unicode and Special Characters

Non-ASCII Tool Names

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);

Special Characters in 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);

Memory and Resource Management

Memory Leak Prevention

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
        );
    }
}

Resource Cleanup

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());
            }
        }
    }
}

Related Documentation

  • Real-World Scenarios - Complete usage examples
  • Error Handling Reference - Detailed error strategies
  • Architecture - Design patterns and threading

Install with Tessl CLI

npx tessl i tessl/maven-org-springframework-ai--spring-ai-mcp

docs

examples

edge-cases.md

real-world-scenarios.md

index.md

tile.json