Five MCP Integration Mistakes That Fail Somewhere Else
Most MCP integration bugs do not fail where they are made. A log line written by the server ends the session one layer down, in the client's transport. A business rule that throws ends up as a JSON-RPC error that the model may never be shown. A tool catalog generated from an OpenAPI file costs you on every model turn, and nothing anywhere reports it as a failure. The cost lands on someone else's side of the protocol, usually later, and usually as a timeout.
This article goes through five of them. Each one is reproduced against the official Java SDK and paired with its fix, and every measurement below is output from one recorded run. The demo is at michalwolinski/mcp-integration-mistakes-java. It has a small order-management server in several variants, plus a scenario runner that plays the MCP client: it launches each variant as a stdio subprocess, prints what the client actually received, and checks every outcome against the claim made here, exiting non-zero if any of them stops holding. The stack is JDK 27 (Temurin 27+35) and io.modelcontextprotocol.sdk:mcp 2.1.0, the current stable release, which negotiates protocol revision 2025-11-25. All of it runs in Docker, with images pinned by digest.
Two neighbouring topics have their own articles. Protocol security (tool poisoning, scopes, step-up authorization) is covered in agent guardrails and permissions, and what the stateless 2026-07-28 revision changes inside a handler is covered in the PHP MCP server article. This one is about wiring: how a correct server reaches the model, and the five places where that path breaks without telling you. Each rule cited here reads the same in 2025-11-25 and in 2026-07-28. Where the newer revision adds something relevant, I say so.
1. A log line on stdout
On the stdio transport, the server's standard output is the protocol. The stdio binding spells it out: the server "MUST NOT write anything to its stdout that is not a valid MCP message", and it "MAY write UTF-8 strings to stderr for any logging purposes". In Java, the usual way to break that rule is a logging configuration that looks harmless. A Logback ConsoleAppender with no <target> writes to System.out, and that is what logback-stdout.xml in the demo does.
I expected the first application log line to break things. What happened is that the handshake failed before any application code ran. The SDK logs every initialize request at INFO, from McpAsyncServer, before it sends the response:
logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}",
With INFO going to stdout, that line reaches the client ahead of the initialize result. Here is what the Java client's stdio transport does with a line it cannot parse (StdioClientTransport, v2.1.0):
while (!isClosing && (line = readLine(processReader, inputMaxSize)) != null) {
try {
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, line);
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
if (!isClosing) {
logger.error("Failed to enqueue inbound message: {}", message);
}
break;
}
}
catch (Exception e) {
if (!isClosing) {
logger.error("Error processing inbound message for line: {}", line, e);
}
break;
}
}
It does not skip the line. It breaks out of the read loop, which completes the inbound stream, so the connection is dead for good. Each later request waits for the full request timeout, 20 seconds by default, and then fails. The transcript:
[logback-stdout.xml --variant=good]
[client log] ERROR StdioClientTransport - Error processing inbound message for line: 08:25:37.823 INFO McpAsyncServer - Client initialize request - Protocol: 2025-11-25, Capabilities: ClientCapabilities[experimental=null, roots=null, samplin
initialize -> RuntimeException: Client failed to initialize by explicit API call (20.1 s)
tools/list -> RuntimeException: Client failed to initialize listing tools (20.0 s)
reserve_stock -> RuntimeException: Client failed to initialize calling tool (20.0 s)
tools/list again -> RuntimeException: Client failed to initialize listing tools (20.0 s)
[logback-stdout-warn.xml --variant=good]
initialize -> protocol 2025-11-25 (0.8 s)
tools/list -> 7 tools (0.0 s)
[client log] ERROR StdioClientTransport - Error processing inbound message for line: 08:26:58.141 WARN StockTools - SKU-00042 is below its reorder point after reserving 3
reserve_stock -> ReactiveException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured) (20.0 s)
tools/list again -> ReactiveException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured) (20.0 s)
[logback-stderr.xml --variant=good]
initialize -> protocol 2025-11-25 (0.6 s)
tools/list -> 7 tools (0.0 s)
[server stderr] 08:27:38.768 WARN StockTools - SKU-00042 is below its reorder point after reserving 3
reserve_stock -> isError=false, text="Reserved 3 x SKU-00042." (0.0 s)
tools/list again -> 7 tools (0.0 s)
[logback-stdout.xml --variant=good --guard-stdout]
initialize -> protocol 2025-11-25 (0.5 s)
tools/list -> 7 tools (0.0 s)
[server stderr] 08:27:39.361 WARN StockTools - SKU-00042 is below its reorder point after reserving 3
reserve_stock -> isError=false, text="Reserved 3 x SKU-00042." (0.0 s)
tools/list again -> 7 tools (0.0 s)
The second block is the dangerous one. Raising the root level to WARN hides the SDK's INFO line, so the handshake passes, tools/list passes, and a smoke test passes too. The session then dies on the first warning anyone logs, here a low-stock warning inside reserve_stock. Raising the level did not fix the bug. It only delayed the failure until the first call that logs something.
The fix has two layers. The first is the obvious one, the same appender pointed at standard error:
<!-- The fix: the same appender, pointed at System.err, which the stdio transport leaves free for logs. -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
That only covers this one appender. A println left over from debugging, a library that prints a banner, or a dependency that brings its own logging configuration can each write to the same channel. The second layer does not depend on everyone behaving: take the real stdout for the transport before anything else runs, and point System.out at stderr for the rest of the process.
// src/main/java/com/wolinski/mcpmistakes/server/DemoServer.java
private static StdioServerTransportProvider guardedTransport() {
PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.out), true, StandardCharsets.UTF_8);
System.setOut(System.err);
return new StdioServerTransportProvider(Tools.JSON, System.in, protocol);
}
DemoServer calls it right after parsing its arguments, before a single tool or logger is created, because anything that runs earlier can still reach the real stdout. The fourth block of the transcript runs the broken logback-stdout.xml again with this guard enabled, and the session survives. Logback's console target writes through System.out each time it logs, so the redirect catches it as well. The 2026-07-28 revision deprecates the protocol's own Logging feature, and its suggested migration is "log to stderr (stdio) or use OpenTelemetry". Stderr is the right place for logs on stdio, not a workaround.
2. An exception where a result belongs
The tools chapter of the specification separates two kinds of failure. Protocol errors (unknown tool, malformed request, server errors) are returned as JSON-RPC errors. Tool execution errors (API failures, input validation, business logic) are returned inside the tool result, with isError: true. Then it says what each one is for: "Clients SHOULD provide tool execution errors to language models to enable self-correction", while for protocol errors clients only "MAY" do so, and "these are less likely to result in successful recovery."
Java makes the wrong choice the easy one. A handler that validates its preconditions the way Java code usually does looks like this:
// src/main/java/com/wolinski/mcpmistakes/server/CancelOrderTools.java
String id = (String) request.arguments().get("order_id");
Order order = book.find(id).orElseThrow(() -> new IllegalArgumentException("Order not found"));
if (false == CANCELLABLE.contains(order.status())) {
throw new IllegalStateException("Illegal state transition");
}
return Tools.text("Cancelled %s.".formatted(id));
The SDK turns any exception that is not an McpError into a protocol error. This is McpServerSession.toJsonRpcError:
if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
return mcpError.getJsonRpcError();
}
logger.warn("Error handling request {} [{}]", request.id(), request.method(), error);
return new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
Objects.requireNonNullElse(error.getMessage(), "Internal error"), null);
So a cancellation refused by a business rule reaches the client with code -32603, which JSON-RPC defines as "Internal error": the code for a crashed server. In the Java client it shows up as a thrown McpError, not as a tool result. Whether the model ever sees it depends on the host. If it does, all it gets is the exception message:
[bad]
[server stderr] 08:27:39.895 WARN McpServerSession - Error handling request d8919720-1 [tools/call]
[server stderr] java.lang.IllegalStateException: Illegal state transition
cancel_order ORD-000002 -> JSON-RPC error -32603: Illegal state transition (0.0 s)
[server stderr] 08:27:39.901 WARN McpServerSession - Error handling request d8919720-2 [tools/call]
[server stderr] java.lang.IllegalArgumentException: Order not found
cancel_order ORD-999999 -> JSON-RPC error -32603: Order not found (0.0 s)
[server stderr] 08:27:39.908 WARN ToolInputValidator - Tool (cancel_order) input validation failed: Validation failed: JSON schema validation errors: [/order_id: does not match the regex pattern ^ORD-[0-9]{6}$]
cancel_order ORD-42 (schema) -> isError=true, text="Tool (cancel_order) input validation failed: Validation failed: JSON schema validation errors: [/order_id: does not match the regex pattern ^ORD-[0-9]{6}$]" (0.0 s)
[good]
cancel_order ORD-000002 -> isError=true, text="Order ORD-000002 is shipped, and only placed or paid orders can be cancelled. Tell the user it cannot be cancelled; do not retry." (0.0 s)
cancel_order ORD-999999 -> isError=true, text="No order ORD-999999 exists. Find the id with search_orders first." (0.0 s)
[server stderr] 08:27:40.444 WARN ToolInputValidator - Tool (cancel_order) input validation failed: Validation failed: JSON schema validation errors: [/order_id: does not match the regex pattern ^ORD-[0-9]{6}$]
cancel_order ORD-42 (schema) -> isError=true, text="Tool (cancel_order) input validation failed: Validation failed: JSON schema validation errors: [/order_id: does not match the regex pattern ^ORD-[0-9]{6}$]" (0.0 s)
The contrast is in the last call of each block, ORD-42. The SDK's own input validation, which checks the arguments against inputSchema before the handler runs, already uses the right channel and returns isError=true with the schema violation spelled out. Only the code we write ourselves takes the other path. The corrected handler returns the failure as data and writes the message for its actual reader, a model that has to decide what to do next:
// src/main/java/com/wolinski/mcpmistakes/server/CancelOrderTools.java
String id = (String) request.arguments().get("order_id");
Optional<Order> found = book.find(id);
if (found.isEmpty()) {
return Tools.error("No order %s exists. Find the id with search_orders first.".formatted(id));
}
Order order = found.get();
if (false == CANCELLABLE.contains(order.status())) {
return Tools.error(("Order %s is %s, and only placed or paid orders can be cancelled. "
+ "Tell the user it cannot be cancelled; do not retry.").formatted(id, order.status()));
}
return Tools.text("Cancelled %s.".formatted(id));
Each message contains what happened (with the value), why, and what to do next. "Do not retry" matters as much as the reason. A model told only that something failed has nothing to stop it from trying again with slightly different arguments. Exceptions still have a place. A null pointer in your own code is a server error, and a protocol error is the honest way to report it. The rule is about failures you expected: if the handler code anticipates a case, its outcome is a result, not an exception. One side note. The SDK forwards getMessage() to the client unchanged, so an exception rethrown from a database driver can put SQL and hostnames in front of whoever is on the other end. That is a second reason to keep expected failures in your own words.
3. A catalog generated from the API
The response to tools/list is not documentation. Hosts put tool definitions in front of the model so it can choose among them, which makes the catalog part of the prompt, sent with every turn of the conversation. An API surface is built for a different reader. Mapping each REST endpoint to its own tool, which is what OpenAPI-to-MCP generators do by default, produces many narrow tools with schemas full of options (pagination, sparse fieldsets, includes, filters) that a model rarely needs and still has to read every time.
The demo builds both catalogs for the same order-management backend. EndpointCatalog does what a generator does: list, get, create, update and delete for each of the API's seven resources, whether the agent needs customers, products and warehouses or not. The task-shaped server has seven tools: six named after what this agent is for (search_orders, get_order, cancel_order, reserve_stock, start_export, export_status) and exports_written, a probe the demo uses to count side effects, left in so that the measured catalog is the one that actually ran. The comparison is therefore not like for like, and that is the point. A generator's catalog is sized by the API, while a designed one is sized by the job.
endpoints 35 tools, tools/list result = 19,651 bytes
good 7 tools, tools/list result = 2,544 bytes
That is almost eight times the bytes (7.7), paid on every turn, before the model has done anything useful. The factor splits into scope and shape. Scope is five times as many tools. Shape is the rest: an endpoint tool averages 561 bytes against 363 for a task tool, 1.5 times as much, because every list endpoint carries the same seven generic parameters. I measured bytes, as the client re-serializes the result, not tokens, because the token count depends on the model's tokenizer and on how the host renders a definition. I also did not measure what 35 similar-looking tools do to the model's choice of tool. That needs a model and a sample size this demo does not have. So treat the following as an engineering rule, not a measured result: every tool in the catalog is a decision the model has to make on every turn. Fewer, task-shaped tools also shorten the work. "Cancel the oldest paid order" is one search_orders with status=paid and limit=1, then one cancel_order, which checks the status rule itself. Against the endpoint catalog, the model has to assemble the same thing from list_orders with a filter object, a sort expression and a page size, and then update_order with a body whose status values it has to guess.
The second half of this mistake is not about size. The 2026-07-28 revision adds a line to the tools chapter: servers "SHOULD return tools in a deterministic order", because that "improves LLM prompt cache hit rates when tools are included in model context." Prompt caches match an exact prefix, and the tool list sits near the start of it. In Java there is an easy way to break this without noticing: build the registry from Map.of.
// src/main/java/com/wolinski/mcpmistakes/server/DemoServer.java
default -> "map".equals(args.get("registration"))
? List.copyOf(Map.of(
"reserve_stock", StockTools.reserveStock(),
"cancel_order", CancelOrderTools.reporting(book),
"search_orders", OrderListingTools.paged(book),
"get_order", OrderListingTools.getOrder(book),
"start_export", exports.starting(),
"export_status", exports.status(),
"exports_written", exports.count()).values())
The iteration order of the JDK's immutable Map.of and Set.of is randomized on purpose, so that nobody comes to depend on it. The JDK 27 source of ImmutableCollections is explicit: its salt "needs to vary sufficiently from one run to the next so that iteration order will vary between JVM runs", and a second flag reverses iteration "stochastically, decided at bootstrap time". That works against a server whose list order is meant to stay fixed. Five launches of the same server:
map launch 1: reserve_stock,cancel_order,get_order,export_status,exports_written,start_export,search_orders
map launch 2: exports_written,start_export,search_orders,reserve_stock,cancel_order,get_order,export_status
map launch 3: exports_written,export_status,get_order,cancel_order,reserve_stock,search_orders,start_export
map launch 4: start_export,exports_written,export_status,get_order,cancel_order,reserve_stock,search_orders
map launch 5: reserve_stock,search_orders,start_export,exports_written,export_status,get_order,cancel_order
map distinct orderings over 5 launches: 5
list launch 1: reserve_stock,cancel_order,search_orders,get_order,start_export,export_status,exports_written
list launch 2: reserve_stock,cancel_order,search_orders,get_order,start_export,export_status,exports_written
list launch 3: reserve_stock,cancel_order,search_orders,get_order,start_export,export_status,exports_written
list launch 4: reserve_stock,cancel_order,search_orders,get_order,start_export,export_status,exports_written
list launch 5: reserve_stock,cancel_order,search_orders,get_order,start_export,export_status,exports_written
list distinct orderings over 5 launches: 1
In the recorded run, five launches of the map version produced five different orders, and the list version produced the same order five times. The exact count changes from run to run, since varying it is the whole point of the salt. What the map never gives you is one fixed order. Unless the host sorts the tools itself, every restart of the server is therefore a likely cache miss for the prefix that follows them. Nothing errors and nothing logs, and the only symptom is a cache-hit rate that drifts down. The fix is a List in a fixed order, or, if the tools have to come from a map, sorting them by name before registering.
4. A result sized by the data
A tool whose output size depends on how much data happens to exist works fine in development and fails in production. The demo's list_orders is the honest version of that tool: it serializes the whole order book.
// src/main/java/com/wolinski/mcpmistakes/server/OrderListingTools.java
static SyncToolSpecification unbounded(OrderBook book) {
return Tools.tool("list_orders", "List all orders.", """
{ "type": "object", "additionalProperties": false }
""", (exchange, request) -> Tools.text(Tools.json(book.all())));
}
list_orders, 5,000 orders -> isError=false, text content 1,266,288 bytes (0.1 s)
search_orders limit=20 -> isError=false, text content 2,407 bytes, next_cursor=ORD-000020 (0.0 s)
search_orders last page -> isError=false, 10 orders, next_cursor=null (0.0 s)
[server stderr] 08:27:48.008 WARN ToolInputValidator - Tool (search_orders) input validation failed: Validation failed: JSON schema validation errors: [/limit: must have a maximum value of 50]
search_orders limit=500 -> isError=true, text="Tool (search_orders) input validation failed: Validation failed: JSON schema validation errors: [/limit: must have a maximum value of 50]" (0.0 s)
[client log] ERROR StdioClientTransport - Inbound message exceeds the maximum allowed size
list_orders, 120,000 orders -> ReactiveException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured) (20.0 s)
tools/list after that -> ReactiveException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured) (20.0 s)
With 5,000 orders the call succeeds and returns 1.27 MB of text. Nothing fails, but the host now has to put about a megabyte of JSON into the context, cut it short, or drop it. None of those is a decision the server should be pushing onto the host. With 120,000 orders the result becomes a single line longer than the Java client will read. StdioClientTransport caps an inbound message at DEFAULT_INPUT_MAX_SIZE = 16 * 1024 * 1024 characters. Past that it logs the error, stops reading, and the connection ends exactly the way it did in mistake 1. The call times out after 20 seconds, and so does everything after it. One oversized result takes down every tool on that server.
The fix is to give the result a fixed upper bound and enforce it in the schema, so the bound holds no matter what the model asks for:
// src/main/java/com/wolinski/mcpmistakes/server/OrderListingTools.java
int limit = Math.min(args.get("limit") instanceof Number n ? n.intValue() : 20, MAX_LIMIT);
List<Order> page = book.page(status, cursor, limit + 1);
boolean more = page.size() > limit;
List<Order> shown = more ? page.subList(0, limit) : page;
Map<String, Object> result = new LinkedHashMap<>();
result.put("orders", shown.stream().map(OrderListingTools::summary).toList());
result.put("next_cursor", more ? shown.getLast().id() : null);
return CallToolResult.builder()
.addTextContent(Tools.json(result))
.structuredContent(result)
.isError(false)
.build();
Several parts carry the weight here. "maximum": 50 in the input schema means the SDK rejects limit=500 before the handler runs; the transcript shows it coming back as a tool error with isError=true. Math.min enforces the same cap in code in case schema validation is ever turned off. The page holds summaries, not line items; anyone who needs the lines calls get_order, the tool the description points to. The result also declares an outputSchema and returns structuredContent, with the same JSON repeated as text because the specification asks for that "for backwards compatibility". That duplication is cheap only because the page is small. The Java client checks structuredContent against the tool's outputSchema, so the schema has to cover every page, including the last one: there next_cursor is null, which is why its type is ["string", "null"] rather than "string". The transcript's "last page" line is that case passing. With the type narrowed to "string" in a copy of the demo, the same call came back without its structured content. For data that really is bulk, such as a full export, the specification allows a resource_link content item instead of inline data. The demo does not use it, because a bounded page already covers what it needs.
There is one case where pagination is the wrong answer: when the caller needs an aggregate. A model asked for "revenue from shipped orders this quarter" should not page 5,000 summaries through its context to add them up. That question needs its own tool, which computes the sum on the server and returns a number.
5. A request that outlives the client
The specification expects both sides to give up on a slow request in a controlled way. Implementations "SHOULD establish timeouts for all sent requests". When one expires, the sender "SHOULD cancel the request", which on stdio means a notifications/cancelled message. SDKs "SHOULD allow these timeouts to be configured on a per-request basis", and a client "MAY" reset the timeout clock when progress arrives. Java SDK 2.1.0 implements less than that. Its timeout is one value per client, 20 seconds by default, applied to every request as a flat Reactor operator in McpClientSession.sendRequest:
})).timeout(this.requestTimeout).handle((jsonRpcResponse, deliveredResponseSink) -> {
Progress notifications do not extend it, and on expiry the client stops waiting without sending anything. The SDK has no cancellation support in either direction: issue #130, "Support cancellation", and PR #803 are both still open. So the server never learns that the client has given up, and keeps working:
[bad] export takes 25 s; client requestTimeout is the SDK default (20 s)
export_orders, attempt 1 -> ReactiveException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured) (20.0 s)
exports_written -> isError=false, text="1" (0.0 s)
export_orders, attempt 2 -> ReactiveException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured) (20.0 s)
exports_written -> isError=false, text="2" (0.0 s)
[good] same 25 s export behind a handle and an idempotency key
start_export, attempt 1 -> isError=false, text="Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is running."
start_export, attempt 2 -> isError=false, text="Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is running." (0.0 s)
export_status -> Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is running.
export_status -> Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is running.
export_status -> Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is running.
export_status -> Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is running.
export_status -> Export exp_1b5b98f2-eff2-41e1-bad9-85d341f4d3a3 is done.
exports_written -> isError=false, text="1" (0.0 s)
[good, failing bucket] 1 s export; the first one fails
export_status -> isError=false, text="Export exp_16a7be8c-f907-4887-ae2c-f6b5d1a5c774 is failed (reporting bucket unavailable); start it again." (0.0 s)
start_export, same key -> isError=false, text="Export exp_4058992f-fbb9-47f3-a060-39c45a1a99ef is running."
export_status -> isError=false, text="Export exp_4058992f-fbb9-47f3-a060-39c45a1a99ef is done." (0.0 s)
exports_written -> isError=false, text="1" (0.0 s)
The [bad] block is the failure in full. The client reports a timeout. The export finishes five seconds later anyway, so exports_written is 1. Then the second attempt, which is a reasonable thing for a host or a model to do after a "failure", writes a second export. The caller saw two failures, while the reporting bucket received two exports. A longer requestTimeout only moves the problem to the first export that runs longer than the new value. The specification also asks for an upper bound on timeouts, so a timeout has to exist somewhere.

[bad] block as a sequence. The timeout ends the wait, not the work: nothing tells the server to stop, so each "failed" attempt still lands an export.The fix is to stop keeping the request open while the work runs. start_export returns a handle immediately, and the work runs off the request path. An idempotency key ties a retry to the export that already exists:
// src/main/java/com/wolinski/mcpmistakes/server/ExportTools.java
String key = (String) request.arguments().get("idempotency_key");
String handle = handleByKey.computeIfAbsent(key, k -> {
String h = "exp_" + UUID.randomUUID();
statusByHandle.put(h, "running");
jobs.submit(() -> {
try {
runExport();
statusByHandle.put(h, "done");
}
catch (RuntimeException e) {
statusByHandle.put(h, "failed (" + e.getMessage() + "); start it again");
handleByKey.remove(k, h);
}
});
return h;
});
return Tools.text("Export %s is %s.".formatted(handle, statusByHandle.get(handle)));
In the [good] block, both attempts get the same handle, the status moves from running to done, and exactly one export is written. The catch matters as much as the key. Without it, an export that throws never leaves "running", and the key that was meant to prevent a duplicate now prevents any retry at all, permanently. A handle needs a failure state, and a failure has to release the key. The [good, failing bucket] block forces that path: the first export fails, the same key starts a new one, and the count still ends at one. One limit of this approach needs saying plainly. An idempotency key protects retries that repeat the same arguments, which is what a host's automatic retry does. A model that decides to "try again" may invent a new key, and then the server sees two different requests. When duplicates are expensive, deduplicate on the business identity, meaning this export for this date and scope, and treat the key as an extra layer rather than the only one. The demo deduplicates on the key alone, so it covers the retry case and nothing more. The 2026-07-28 revision makes this pattern official as the io.modelcontextprotocol/tasks extension, with polling through tasks/get. Java SDK 2.1.0 does not implement it yet; issue #668 is open. Until it does, a handle tool and a status tool do the same job.
When this advice is wrong
Mistake 1 belongs to stdio. On Streamable HTTP the response travels on its own HTTP stream, and stdout is just a log. The same goes for the 16 Mi character limit, which belongs to the Java stdio client. Other clients and the HTTP transport have their own limits, which I did not measure. If you only ever run over HTTP, the first mistake does not apply to you. The fourth one still does, because the cost to the context window does not depend on the transport.
An endpoint-shaped catalog can be the right call. If the consumer is a developer tool driven by a person who wants the raw API, or the API is small enough that the endpoints already are the tasks, generating tools is cheap and honest. Hosts that load tool definitions on demand, rather than sending all of them every turn, weaken the size argument. They do not weaken the ordering argument.
A handle is state. Handles have to be stored, expired and authorized on every call. The specification's own note on stateful tools reminds you that a handle is "a name, not a capability". The demo keeps its handles in process memory and never expires them, which a real server cannot afford. For operations that reliably finish in well under a second, a plain call is simpler and better.
"Do not retry" is a request, not a rule. A model can ignore what a tool result tells it to do. The message improves the odds, while the server is what actually prevents the damage: a cancellation that refuses shipped orders, an export that deduplicates. The text of an error result is guidance for the model, not access control.
The demo is one SDK at one version. The break on an unreadable line, the missing cancellation and the flat timeout are behaviours of Java SDK 2.1.0, read from its source and observed in the run. The specification leaves a client free to skip a bad line, and other SDKs make other choices. What carries over to them is the question to ask your own client, not the exact answer this one gives.
A checklist for your own server
- Nothing but protocol on stdout. Point every console appender at
System.err, then reserve the real stdout for the transport and redirectSystem.outon startup, so that the next strayprintlncannot end the session. - Expected failures are results. Return
isError: truewith the value, the reason and the next step. Keep exceptions for bugs. - Design the catalog as a prompt. Name tools after tasks, not endpoints. Keep the order fixed by registering from a
List, never fromMap.oforSet.of. - Every result has an upper bound. Put a maximum in the schema, use a cursor, return summaries by default, and give aggregate questions their own tools.
- Nothing slow holds a request open. Return a handle, give it a failure state that releases its key, deduplicate retries with an idempotency key and expensive work on its business identity, and assume the client gave up at the first timeout without telling you.
What the five have in common is that the protocol call succeeds, or fails somewhere other than where the mistake was made. The handshake log line is written by the server and kills the client's read loop. The exception is thrown in a handler and arrives as a crashed-server error. The catalog is generated once and costs something on every turn. The oversized result is valid JSON that the client refuses to read. The timed-out export completes and then gets written again. In each case the fix moves the decision back to the side that has the information: the server knows which lines are protocol, which failures it expected, how big its answers can get and how long its work takes. Making those limits explicit is the server's job, because the client and the model have no way to work them out.