Module 6: Error Handling
Duration: ~25 minutes | Level: Intermediate | Prerequisites: Module 5: Implementing Prompts
Two Kinds of Errors
MCP distinguishes two fundamentally different error categories. Confusing them leads to bad AI behaviour.
Tool Errors (Application-Level)
A tool error means the tool ran but encountered a problem: database timeout, file not found, API returned an error, input failed validation.
Return these as CallToolResult with isError: true. Use the builder; CallToolResult is a 4-field record (content, isError, structuredContent, _meta) and the builder keeps unused fields out of sight:
return CallToolResult.builder()
.isError(true)
.addTextContent("Database connection timed out after 30s")
.build();
When the AI receives isError: true, it knows the tool call failed. It can:
- Retry with different arguments
- Try a fallback approach
- Ask the user what to do
- Explain the failure in the response
Protocol Errors (Infrastructure-Level)
A protocol error means the MCP communication itself broke: malformed message, unknown method, initialisation failed.
These are returned as JSON-RPC error objects. The MCP Java SDK handles most protocol errors automatically; you'll rarely construct these manually. But throwing an unhandled exception from a tool handler will result in a protocol error, not a clean tool error. Always catch exceptions in handlers.
Validation Pattern
For parameter validation, validate early and return descriptive errors:
(exchange, request) -> {
Map<String, Object> args = request.arguments();
// Validate required parameters
String email = (String) args.get("email");
if (email == null || email.isBlank()) {
return CallToolResult.builder().isError(true).addTextContent("'email' is required and cannot be empty").build();
}
// EMAIL_PATTERN is a class-level constant you declare once:
// private static final Pattern EMAIL_PATTERN =
// Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
// Or use a real library (Apache Commons Validator: EmailValidator) for stricter checks.
if (!EMAIL_PATTERN.matcher(email).matches()) {
return CallToolResult.builder().isError(true).addTextContent(
"Invalid email format: '" + email + "'. Expected: [email protected]"
).build();
}
// Validate range. JSON numbers arrive as Number; cast accordingly.
Number limit = (Number) args.get("limit");
if (limit != null && (limit.intValue() < 1 || limit.intValue() > 100)) {
return CallToolResult.builder().isError(true).addTextContent(
"'limit' must be between 1 and 100, got: " + limit
).build();
}
// Proceed with business logic...
}
Timeout Handling
Always set timeouts on external calls. A tool that blocks forever will hang the AI's response:
(exchange, request) -> {
try {
var result = externalApi.call(request.arguments().get("query"))
.get(10, TimeUnit.SECONDS); // hard timeout
return CallToolResult.builder().addTextContent(toJson(result)).build();
} catch (TimeoutException e) {
return CallToolResult.builder().isError(true).addTextContent(
"External API timed out after 10 seconds. The service may be unavailable."
).build();
} catch (ExecutionException e) {
log.error("API call failed", e.getCause());
return CallToolResult.builder().isError(true).addTextContent(
"API error: " + e.getCause().getMessage()
).build();
}
}
Structured Error Messages
Error messages go directly into the AI's context. Write them as you would write a useful error for a developer:
Bad:
"Error"
"Something went wrong"
"null"
Good:
"File not found: /documents/report.pdf. Check that the path is correct and the file exists."
"Database query failed: table 'orders' does not exist in schema 'public'. Available tables: [users, products, reviews]"
"Rate limit exceeded for API key. Retry after 60 seconds."
The AI can use a good error message to give the user a helpful response or decide how to recover. A bad error message gives it nothing to work with.
Exception Handling in Async Tools
Async tool handlers return Mono<CallToolResult>. Always recover from failures with onErrorResume(...); an unhandled error in the returned Mono reaches the SDK as a failed signal and is converted to a protocol error, confusing the AI and potentially breaking the session.
(exchange, request) -> {
String id = (String) request.arguments().get("id");
return Mono.fromFuture(repository.findAsync(id))
.map(entity -> {
if (entity == null) {
return CallToolResult.builder().isError(true).addTextContent("Not found: " + id).build();
}
return CallToolResult.builder().addTextContent(toJson(entity)).build();
})
.onErrorResume(e -> {
log.error("Unexpected error in find tool", e);
return Mono.just(CallToolResult.builder().isError(true).addTextContent(
"Internal error: " + e.getMessage()
).build());
});
}
If your backend already returns a Mono (Spring Data Reactive, WebClient), drop the Mono.fromFuture(...) wrapper.
Logging Best Practices
Log enough to debug without flooding stderr:
private static final Logger log = LoggerFactory.getLogger(MyToolHandler.class);
// Log tool calls at DEBUG (too noisy for INFO in production)
log.debug("search_customers called: query={}, limit={}", query, limit);
// Log errors at ERROR with the exception
log.error("Database query failed for customer search", exception);
// Log slow operations at WARN
if (duration.toMillis() > 5000) {
log.warn("search_customers slow: {}ms for query='{}'", duration.toMillis(), query);
}
// Never log sensitive data (PII, credentials, tokens)
// Bad:
log.info("User authenticated: username={}, password={}", username, password);
// Good:
log.info("User authenticated: username={}", username);
Key Takeaways
- Tool errors (
isError: true) versus protocol errors (JSON-RPCerror): use the right one - Validate inputs early, return descriptive errors immediately
- Always set timeouts on external calls; handle
TimeoutExceptionexplicitly - Write error messages that help the AI reason about the failure
- Always attach
onErrorResume()to async toolMonos; an unhandled error becomes a protocol error - Log to
stderr, neverstdout; never log sensitive data
Companion code
Every pattern in this module is exercised by the projects/mcp-java-sdk/tools-server/ sub-module: argument coercion, descriptive error messages, the async Mono.onErrorResume recovery path, and a 10-second timeout(...) on the customer-search backend.