Skip to main content

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-RPC error): use the right one
  • Validate inputs early, return descriptive errors immediately
  • Always set timeouts on external calls; handle TimeoutException explicitly
  • Write error messages that help the AI reason about the failure
  • Always attach onErrorResume() to async tool Monos; an unhandled error becomes a protocol error
  • Log to stderr, never stdout; 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.

→ Module 7: Testing