Class 6: Error Handling
Duration: ~55 minutes | Level: Intermediate | Prerequisites: Class 5: Implementing Prompts
This class builds directly on Class 5. If you skipped it, clone the class_5 branch to start from the same place:
git clone --branch class_5 https://github.com/the-mcp-guy/mcp-java-sdk-course.git
When the Backend Fails
Everything so far assumed the customer database answers, and it will not always.
Picture the Acme support desk on a bad morning. The database has run out of connections: the pool of open connections the application shares is fully in use. An agent asks their assistant about Globex, search_customers fires, and the backend throws an exception.
The second-to-last arrow is the one this class is about. What the server puts in that reply decides whether the agent reads "I could not reach the customer system" or a confident answer the model invented. So this class is not about try/catch. It is about what your server puts on the wire when things break: one kind of mistake leaves the model free to invent a customer, and another leaves the conversation waiting until the client's timeout fires.
You have already seen three different error shapes without being told how the SDK chose them: -32002 in Class 4, -32602 in Class 5, and isError: true in Class 3. This class is where that becomes a decision you make on purpose.
What We'll Build
To see how a failure looks on the wire, something has to fail. So this class adds a small switch to the fake customer database that makes it hang or fail when you ask it to, and a lookup tool registered twice, once written correctly and once with a line missing.
That switch exists for the demonstration, not as a pattern to copy. In a real project you would cover these paths with tests, which is Class 7. The switch is here only so the failures can be triggered from a terminal and the JSON they produce can be read directly.
| Piece | What it does |
|---|---|
a Failure flag on CustomerRepository | tells the fake database to hang or fail, so a failure can be triggered on demand |
FindCustomerTool | one lookup registered twice, correct and incorrect, so both can be called |
ErrorsMcpServer | starts in a chosen failure mode so you can send it requests |
Very little of this is new MCP, and search_customers is not modified at all: it comes from Class 3 and is handed a database that misbehaves.
The flag defaults to Failure.NONE, so every server from Classes 3 to 5 behaves exactly as before.
Two Kinds of Failure
MCP separates these, and confusing them is the mistake that produces bad model behaviour.
A tool error means the tool ran and could not do its job. The database was down, the file was missing, the argument was nonsense. This is a successful JSON-RPC response whose result carries isError: true. The model sees it, reasons about it, and can retry, try something else, or tell the user.
A protocol error means the request itself was not something the server could act on. Unknown method, unknown tool name, malformed message. This is a JSON-RPC error object with a numeric code, and it goes to the client application. It does not arrive as a tool result.
The question to ask is whether the server could act on the request at all:
Only a protocol error stops at the client application. Everything the handler produces, success or failure, is written as a result and reaches the model. Whether the model is also shown a protocol error is the client's choice: the specification says clients SHOULD pass tool errors to the model and MAY pass protocol errors.
| Tool error | Protocol error | |
|---|---|---|
| On the wire | "result": { …, "isError": true } | "error": { "code": …, "message": … } |
| Who reads it | the model | the client |
| Use for | anything that happened while doing the work | anything that stopped the work from starting |
| In Java | CallToolResult.builder().isError(true) | throw McpError |
Where This Code Goes
src/main/java/com/themcpguy/
├── tools/ Class 3
│ └── CustomerRepository.java <- gains a failure flag
└── errors/ <- new package, all of Class 6
├── FindCustomerTool.java
└── ErrorsMcpServer.java this class's entry point
A flag on the repository
CustomerRepository was written as an interface back in Class 3 so the implementation could be swapped out, and swapping in one that fails is exactly what a test would do. For this class a switch on the existing implementation is easier to reach, because it can be set from the command line without writing a test first.
Add the enum and a second factory method to the interface. Both are new here; nothing in Classes 3 to 5 had them:
/** Ways the fake backend can misbehave on searchAsync, so Class 6 can watch handlers react. */
enum Failure {
/** Behaves normally. What Classes 3 to 5 use. */
NONE,
/** Never answers in time: sleeps well past any sensible timeout. */
SLOW,
/** Fails immediately, the way a database with no connections would. */
BROKEN
}
static CustomerRepository inMemory() {
return inMemory(Failure.NONE);
}
/** Class 6: the same repository, told to misbehave. */
static CustomerRepository inMemory(Failure failure) {
return new InMemory(failure);
}
inMemory() without an argument stays and delegates, which keeps Classes 3, 4 and 5 compiling and behaving exactly as before: none of those servers passes a flag.
Then inside InMemory, a field and a constructor:
private final Failure failure;
InMemory(Failure failure) {
this.failure = failure;
}
and searchAsync gains one call at the top:
@Override
public CompletableFuture<List<Customer>> searchAsync(String query, int limit) {
String needle = query.toLowerCase(Locale.ROOT);
return CompletableFuture.supplyAsync(() -> {
misbehave();
return customers.stream()
.filter(c -> matches(c, needle, query))
.limit(limit)
.toList();
});
}
/** Only ever does anything when Class 6 asks for a broken backend. */
private void misbehave() {
switch (failure) {
case SLOW -> {
try {
Thread.sleep(Duration.ofSeconds(30));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while pretending to be slow", e);
}
}
case BROKEN -> throw new IllegalStateException(
"customer-db: no connections available in pool");
case NONE -> {
}
}
}
with import java.time.Duration; added at the top of the file. The Thread.sleep(Duration) overload arrived in Java 19, so it needs the maven.compiler.release of 21 that Class 1's pom.xml already sets.
Thirty seconds is deliberately longer than the ten-second timeout SearchCustomersTool already carries, so SLOW means "never answers", not "answers late".
What the SDK Already Checks
Before writing a single validation line, find out what you do not have to write. search_customers declares this schema, from Class 3:
"query": { "type": "string" },
"required": ["query"]
You can check this before writing any of this class's code. Start Class 3's server, exactly as it already is:
java -cp target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar com.themcpguy.tools.ToolsMcpServer
Send each block below on its own, and do not press Enter after pasting. A copied line already ends in a newline, so the paste submits it by itself; pressing Enter adds a blank line, and a blank line kills the connection:
ERROR i.m.s.t.StdioServerTransportProvider - Error processing inbound message
The transport reads stdin one line at a time, and a line it cannot parse as a JSON-RPC message ends that read loop and closes the session. The process itself stays alive and still accepts typing, but nothing you send is ever answered again, which looks exactly like a hang. It cannot be recovered: Ctrl+C and start the server again.
Open the session:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
Then acknowledge it. Nothing comes back from this one, which is correct:
{"jsonrpc":"2.0","method":"notifications/initialized"}
Call it and leave query out entirely. The SDK answers before the tool's handler runs: it validates the arguments against the schema first.
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_customers","arguments":{}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Tool (search_customers) input validation failed: Validation failed: JSON schema validation errors: [: required property 'query' not found]"}],"isError":true}}
Send the wrong type, and it stops that too:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_customers","arguments":{"query":42}}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Tool (search_customers) input validation failed: Validation failed: JSON schema validation errors: [/query: integer found, string expected]"}],"isError":true}}
It comes back as a tool error, not a protocol error. isError: true, inside a normal result. That is the right call: a model that sent bad arguments should see the complaint and correct itself, and it can only do that if the message reaches it as a result.
This is the opposite of prompts. Class 5 showed that prompts/get does not validate its arguments at all, so required(true) was documentation and every check had to be yours. For tools/call the schema is enforced, because a tool's input schema is machine-readable in a way a prompt's argument list is not.
Send "query": " " and the schema is perfectly happy: it is a string, and it is present. Your handler's own check is what catches it:
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_customers","arguments":{"query":" "}}}
{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"'query' is required and must be a non-blank string"}],"isError":true}}
So the division of labour is: JSON Schema for structure, your handler for meaning. Presence, type and range go in the schema, where the model can read them before calling. Blankness, business rules and anything needing a lookup stay in Java. A presence check in both places is harmless, and the schema rejects the call before the Java copy can run.
The Handler That Answers Nothing
Reactor cannot carry null. An Optional that is empty, a CompletableFuture that completes with null, a filter that rejects every element: each of these produces an empty Mono, and an empty Mono means your handler finishes without emitting a result. The SDK turns the handler's value into a JSON-RPC response with a map, and a map on an empty Mono is skipped, so no JSON-RPC response is written at all and the client waits until its own timeout expires.
Mono.justOrEmpty(Optional.empty()); // completes without emitting anything
Why a new tool, rather than reusing search_customers? Because that one cannot produce the bug. It returns a List, and a search that matches zero customers still returns a list, just an empty one. An empty list is a value, so the Mono emits it and the handler finishes normally. The trap needs a lookup that returns one thing or nothing, because "nothing" does not carry a value, and that is the only reason FindCustomerTool exists.
ErrorsMcpServer registers this lookup twice, once with a line of Reactor called defaultIfEmpty and once without it. That one line is the whole difference on the wire:
The empty Mono happens in both branches, and what differs is whether anything is left for the SDK to turn into a response.
Most of the file is the supporting code every tool in this course has: a schema constant, a spec(), and JSON serialisation. Only the middle of find(...) is worth reading closely. Create src/main/java/com/themcpguy/errors/FindCustomerTool.java:
package com.themcpguy.errors;
import com.themcpguy.tools.CustomerRepository;
import com.themcpguy.tools.Results;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.util.Map;
/**
* Finds exactly one customer, which is where the emptiness trap lives: findFirst()
* gives an Optional, Mono.justOrEmpty turns "absent" into an empty Mono, and an empty
* Mono means the handler never emits at all.
* <p>
* Registered twice by ErrorsMcpServer, once with defaultIfEmpty and once without, so
* the difference is visible on the wire.
*/
public final class FindCustomerTool {
private static final Logger log = LoggerFactory.getLogger(FindCustomerTool.class);
private static final String SCHEMA = """
{
"type": "object",
"properties": {
"customerId": {
"type": "string",
"description": "Exact customer id, for example CUST-2"
}
},
"required": ["customerId"]
}
""";
private final McpJsonMapper jsonMapper;
private final CustomerRepository repository;
private final boolean handleEmpty;
public FindCustomerTool(McpJsonMapper jsonMapper, CustomerRepository repository, boolean handleEmpty) {
this.jsonMapper = jsonMapper;
this.repository = repository;
this.handleEmpty = handleEmpty;
}
public AsyncToolSpecification spec() {
String name = handleEmpty ? "find_customer" : "find_customer_broken";
Tool definition = Tool.builder(name, jsonMapper, SCHEMA)
.description(handleEmpty
? "Look up one customer by exact id. Reports clearly when there is no such customer."
: "The same lookup with no defaultIfEmpty, kept only to show what a silent handler does.")
.build();
return AsyncToolSpecification.builder()
.tool(definition)
.callHandler((exchange, request) -> find(request.arguments()))
.build();
}
Mono<CallToolResult> find(Map<String, Object> arguments) {
Object raw = arguments.get("customerId");
if (!(raw instanceof String customerId) || customerId.isBlank()) {
return Mono.just(Results.error("'customerId' is required and must be a non-blank string"));
}
Mono<CallToolResult> found = Mono.fromFuture(() -> repository.searchAsync(customerId, 1))
// findFirst() is an Optional, and an absent Optional becomes an EMPTY Mono.
.flatMap(matches -> Mono.justOrEmpty(matches.stream()
.filter(c -> c.id().equalsIgnoreCase(customerId))
.findFirst()))
.map(this::toResult);
if (handleEmpty) {
// Without this line the handler completes without emitting, the SDK writes no
// response, and the client waits until its own timeout expires.
found = found.defaultIfEmpty(Results.error("No customer with id '" + customerId + "'"));
}
return found.onErrorResume(e -> {
log.error("find_customer failed for {}", customerId, e);
return Mono.just(Results.error("Could not reach the customer database. " + e.getMessage()));
});
}
private CallToolResult toResult(CustomerRepository.Customer customer) {
try {
return CallToolResult.builder()
.addTextContent(jsonMapper.writeValueAsString(customer))
.build();
} catch (IOException e) {
return Results.error("Could not serialise the customer: " + e.getMessage());
}
}
}
Read the chain and look for the mistake. searchAsync returns a list, findFirst() returns an Optional, Mono.justOrEmpty is the correct way to turn an Optional into a Mono, and map transforms what is there. Every step is the normal way to write this in Reactor. The bug is the step that is missing, and a reviewer reading the chain sees only the steps that are there.
defaultIfEmpty is the whole fix, and the rule that follows is worth keeping: any handler whose chain can finish without emitting needs defaultIfEmpty or switchIfEmpty. If you cannot say for certain that a chain always emits, it does not.
The wire stays silent, so pasting requests into a terminal cannot catch this. Class 7 catches it with one assertion: it subscribes to this same Mono with Reactor's StepVerifier, and with the defaultIfEmpty line deleted that assertion fails at once:
FindCustomerToolTest.shouldStillEmitWhenCustomerMissing
expectation "assertNext" failed (expected: onNext(); actual: onComplete())
The boolean handleEmpty exists only so this class can register the tool both ways. Real code would always have the line.
Wiring It Up
Create src/main/java/com/themcpguy/errors/ErrorsMcpServer.java:
package com.themcpguy.errors;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.themcpguy.tools.CustomerRepository;
import com.themcpguy.tools.CustomerRepository.Failure;
import com.themcpguy.tools.SearchCustomersTool;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Class 3's search tool wired to a backend that misbehaves on purpose, plus a lookup
* registered twice to show what a silent handler costs.
* <p>
* Pass NONE, SLOW or BROKEN as the first argument. Default is BROKEN, because that is
* the interesting one.
*/
public class ErrorsMcpServer {
private static final Logger log = LoggerFactory.getLogger(ErrorsMcpServer.class);
public static void main(String[] args) throws Exception {
Failure failure = args.length > 0 ? Failure.valueOf(args[0].toUpperCase()) : Failure.BROKEN;
McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var transportProvider = new StdioServerTransportProvider(jsonMapper);
// The only line that differs from Class 3's server.
CustomerRepository customers = CustomerRepository.inMemory(failure);
var search = new SearchCustomersTool(jsonMapper, customers);
var findOk = new FindCustomerTool(jsonMapper, CustomerRepository.inMemory(), true);
var findBroken = new FindCustomerTool(jsonMapper, CustomerRepository.inMemory(), false);
McpServer.async(transportProvider)
.serverInfo("acme-errors", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(search.spec(), findOk.spec(), findBroken.spec())
.build();
log.info("acme-errors started (stdio) with backend failure mode {}", failure);
Thread.currentThread().join();
}
}
SearchCustomersTool is not modified, or even recompiled. It is Class 3's file, imported unchanged. The only difference is the repository handed to its constructor, which is what the CustomerRepository interface was written for: the constructor argument is where a different implementation goes in.
The two FindCustomerTool instances get healthy repositories, because they are here to show the empty-Mono problem, not a backend failure.
Try It
Build, then run the server three times, once per failure mode.
mvn package
Paste only the blocks marked "You send this", one at a time, and never press Enter on an empty line. Every run below is a fresh process, so each one starts again at the handshake.
Run 1: a healthy backend
java -cp target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar com.themcpguy.errors.ErrorsMcpServer NONE
Initialise the communication first, every time: send the initialize line, then notifications/initialized, each pasted on its own. Until both have arrived, the server holds any other request without answering it.
The two handshake lines (click to expand)
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
A search that works, so you know what success looks like here:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_customers","arguments":{"query":"globex"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[{\"id\":\"CUST-2\",\"name\":\"Globex Industries\",\"email\":\"[email protected]\",\"accountStatus\":\"ACTIVE\"}]"}],"isError":false}}
Note "isError":false is stated explicitly rather than left out. The three rejected arguments from earlier work here too, since this server registers the same tool.
Now the empty case, handled properly:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"find_customer","arguments":{"customerId":"NOPE"}}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"No customer with id 'NOPE'"}],"isError":true}}
And the same lookup without defaultIfEmpty:
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"find_customer_broken","arguments":{"customerId":"NOPE"}}}
Nothing comes back. No result, no error and no log line, however long you wait.
A wrong answer gets noticed. A call that never answers looks like a slow network, so a real client waits until its own timeout fires and then gives up on the request, and the user waits through all of it without learning what went wrong.
The session itself is fine, though. Send a request with a different id and it answers immediately:
{"jsonrpc":"2.0","id":5,"method":"tools/list","params":{}}
You will get your three tools back. Only that one request is lost, and the connection itself is undamaged. In production the symptom is one hung conversation, which is much harder to notice than a server that has stopped.
Press Ctrl+C.
Run 2: a backend that fails
java -cp target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar com.themcpguy.errors.ErrorsMcpServer BROKEN
Initialise the communication first. This is a new process, so send the two handshake lines again, each pasted on its own.
The two handshake lines (click to expand)
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_customers","arguments":{"query":"globex"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Internal error: customer-db: no connections available in pool"}],"isError":true}}
The repository threw an exception, and the catch-all onErrorResume at the bottom of search(...) turned it into a tool error. The model is told the database is unreachable, in words, and can say so without guessing. Had the exception escaped instead, the client would have received a -32603 protocol error, and the model would not have been given anything to reason about.
Press Ctrl+C.
Run 3: a backend that hangs
java -cp target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar com.themcpguy.errors.ErrorsMcpServer SLOW
Initialise the communication first. This is a new process, so send the two handshake lines again, each pasted on its own.
The two handshake lines (click to expand)
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_customers","arguments":{"query":"globex"}}}
Now wait. Ten seconds later, not thirty:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Backend timed out after 10s"}],"isError":true}}
The backend is still asleep and will be for another twenty seconds. Your handler stopped waiting because of one line written back in Class 3:
.timeout(BACKEND_TIMEOUT)
Without it this call behaves like find_customer_broken: silence, for however long the backend takes. The timeout is what turns a hang into a tool error, and a tool error is something the model can read and act on.
Press Ctrl+C.
Choosing the Right Failure
Three ways a handler can fail, and they are not interchangeable.
| Situation | What you write | What goes on the wire | Who reads it |
|---|---|---|---|
| the work ran and failed: backend down, nothing found, argument rejected on business grounds | Results.error(...) | result with isError: true | the model, which can retry or tell the user |
| the request could not be acted on: unknown resource, missing prompt argument | throw McpError | error with -32002 or -32602 | the client application |
| an exception escaped the handler | this is the case to avoid | error with -32603, carrying your Java class name | the client application |
Return isError: true for anything that happened while doing the work. This is the default and it covers most cases.
return Mono.just(Results.error("Backend timed out after 10s"));
Throw McpError when the request could not be acted on at all, which is what Classes 4 and 5 did. A resource that does not exist is -32002; an argument a prompt cannot work without is -32602.
// Inside a prompts/get handler, as in Class 5.
throw McpError.builder(ErrorCodes.INVALID_PARAMS).message("'customerId' is required").build();
A missing tool argument is answered differently: it stays a tool error, so the model can read the complaint and call again. That is what find(...) returns at the top of its handler, and what the SDK's schema check returns for search_customers.
Never let a plain exception escape. It becomes -32603 INTERNAL_ERROR, which tells the caller only that your server broke, and it carries your Java class names out to whoever is listening:
"error":{"code":-32603,"message":"…","data":"IllegalStateException: …"}
Both the catch-all onErrorResume in search(...) and the McpError calls in Classes 4 and 5 exist to stop that happening.
The MCP specification lists "API failures" as tool execution errors, the tier clients SHOULD pass to the model. The Java SDK's own server documentation answers the same case the other way, in its decision guide:
| Situation, in the SDK documentation's words | Its answer |
|---|---|
| Domain validation failure | CallToolResult with isError=true |
| Infrastructure / unexpected error | Throw McpError or let it propagate |
The same page reserves uncaught exceptions for "truly unexpected failures (e.g., infrastructure errors such as DB timeout)". This course follows the specification: a model told in words that the database is unreachable can tell the user, while a -32603 reaches the client application and the model does not get anything it can pass on.
The same care applies to the text inside isError. It reaches the model, and the model usually repeats it to the user, so "customer-db: no connections available in pool" tells a stranger what your datastore is called and how it broke. OWASP files this under LLM02:2025 Sensitive Information Disclosure, and CWE-209 is the general form of it. A production server logs the exception in full and returns one sentence written for a person:
log.error("find_customer failed for {}", customerId, e);
return Mono.just(Results.error("The customer system is unavailable. Please try again shortly."));
This class prints the raw message instead, so the failure is readable in a terminal.
isErrorClass 4 pointed out that a resource read either produces contents or throws an exception. Now the reason is visible: isError exists so a model can reason about a failed action it chose to take. A resource read is requested by the client application, so the failure travels back to the client application, and a model is not part of that path at all.
What This Class Leaves Out
The error handling above works, and it is still the minimum. Four things a production server would add to its failure handling are missing from it, and none of them appear later in the course either, so they are named here.
| What is missing | What it does | Where it comes from |
|---|---|---|
| retries with backoff | calls again after a failure that is likely temporary, waiting longer before each attempt | Reactor, as Retry.backoff(...) |
| a circuit breaker | stops calling a dependency that keeps failing, then lets one request through later to see whether it recovered | Resilience4j, whose breaker moves between closed, open and half-open |
| correlation ids | puts one id on the call, the log line and the error text, so a user can quote it back and you can find that call | your own code |
| messages written for two audiences | one sentence a support agent can act on, with the detail kept in the log | your own code |
Retrying is the item that needs the most judgement. A connection pool that is momentarily empty usually has a free connection a second later, so repeating a read is often right. A write that timed out may already have been applied, and repeating it would do the work twice.
// Illustrative: three more attempts, the first after 200ms, for a read you know is safe to repeat.
.retryWhen(Retry.backoff(3, Duration.ofMillis(200)))
None of the four change the shape of what this class teaches. They sit inside the same onErrorResume, and the choice between isError and McpError is the same with or without them.
The specification's security considerations for tools add a separate list: servers MUST validate all tool inputs, implement proper access controls, rate limit tool invocations and sanitize tool outputs. Class 8 covers those.
Class 9 answers a different question: how to reach the server at all, with Spring Boot, the Streamable HTTP transport instead of stdio, and the Origin checks that go with it. The four items above are about how your server behaves when a dependency fails, and they are ordinary Reactor and library work rather than anything MCP-specific.
What We Built
src/main/java/com/themcpguy/errors/
├── FindCustomerTool.java the emptiness trap, registered both ways
└── ErrorsMcpServer.java Class 3's search tool on a failing backend
Plus one flag on CustomerRepository, which left every earlier class untouched.
We have now seen six ways for a call to fail, each one on the wire. Only the last is silent:
| What went wrong | What the client got |
|---|---|
| required argument missing | tool error, from the SDK's schema check |
| argument of the wrong type | tool error, from the SDK's schema check |
| argument blank | tool error, from your handler |
| backend threw an exception | tool error, from onErrorResume |
| backend hung | tool error, from .timeout(...), after 10s |
| chain completed empty | no response at all |
What's Next
You have been testing all of this by pasting JSON into a terminal and reading the replies. That works for a few requests and becomes impractical quickly. Every handler in this course was written as plain Java behind a spec() so that a test can call it directly, without a server, a transport or a terminal. The next class does exactly that.
Further Reading
- MCP specification: Tools, Error Handling: the two tiers this class is built on, with worked examples of each.
- MCP specification: Lifecycle, Timeouts: how long a client is expected to wait, and when it may restart that clock on a progress notification.
- MCP specification: Cancellation: the notification a client sends when it gives up on a request.
- MCP Java SDK: MCP Server: the SDK's own tiers and decision guide.
- Mono, Reactor API documentation: when
justOrEmpty,defaultIfEmptyandswitchIfEmptyemit, and when they do not. - CircuitBreaker, Resilience4j: the closed, open and half-open states and the failure-rate window.
- LLM02:2025 Sensitive Information Disclosure, OWASP: why error text naming internal systems is a disclosure problem once a model can repeat it.
- JSON-RPC 2.0: Error object: the reserved codes MCP inherits, and the
code,messageanddatafields.
Sources
- MCP specification: Tools, Error Handling: that API failures and input validation errors are reported with
isError: true, and that clients SHOULD pass tool errors to the model and MAY pass protocol errors. The same page's security considerations name access control and rate limiting. - MCP specification: Resources, Error Handling: that
-32002is the code for a resource that does not exist. - MCP specification: Lifecycle, Timeouts: that a sender which does not get a success or error response within its timeout stops waiting and issues a cancellation notification.
- MCP Java SDK: MCP Server: the decision guide rows quoted here, and the SDK's advice on uncaught exceptions.
- Mono, Reactor API documentation: that
justOrEmptyon an absentOptionalemits onlyonComplete, and thatdefaultIfEmptysupplies a value when theMonocompletes without one. - Thread, Java SE 21 API documentation: that
Thread.sleep(Duration)was added in Java 19. - CWE-209: Generation of Error Message Containing Sensitive Information: the weakness behind returning an exception message to a caller.