LangChain4j 1.22.0 now forwards a cancelled async MCP tool call to the MCP request and the stdio wire. What happens to the external process and the database is still the server's job.
The admin asks the agent to close every job posting older than 60 days and notify the applicants. Ten seconds later they press Stop. The spinner disappears. The database is still on batch 3. That gap is the operational question behind three small fixes in LangChain4j 1.22.0 , published on 8 October 2026. The release headline is decision models and prompt caching. The MCP lines are easy to skip: async tool-result attributes, cancellation propagation, and a working directory for stdio servers. I read the pull requests and the source because the scenario above is exactly the kind of admin action I think about for ITJobOpportunities. The example here is fictional. No production system is described. The Stop button worked. The job did not stop. The call path has more hops than the UI shows. The agent asks the model for a tool call. The AI Service hands it to an MCP tool executor. The MCP client sends tools/call over a transport. A server process receives it, and that server may start a worker, open a JDBC statement, or call a third-party API. The side effect lives at the bottom of that chain. The Stop button lives at the top. "Cancelled" is therefore not one state. A caller's future can be cancelled while the request is still pending. A request can be cancelled while the server keeps working. A server can stop while a statement it issued keeps running. A statement can stop after two batches have already committed and two batches of emails are already queued. Each of those is a different promise, and only the first one is visible to the person who pressed the button. What LangChain4j 1.22.0 changed Cancellation propagation ( #6584 ). Before the fix, DefaultMcpClient.executeToolAsync() returned a future derived with handle(...) . Cancelling a derived CompletableFuture does not cancel the stage it came from, so the caller saw a cancelled call while the MCP request stayed pending until its timeout. The issue reproduced it in four lines: returned.isCancelled=true , transport.isDone=false . Now the client cancels the in-flight response future, removes the pending operation, and sends notifications/cancelled with reason "Cancelled" when the transport needs one. There is one behavior change worth a release note in your own code. A server-side cancellation now fails executeToolAsync() with CancellationException , the same as the blocking executeTool() , instead of wrapping it in ToolExecutionException . If your error handling only catches the wrapper, a cancel is now a different branch. Async tool-result attributes ( #6581 ). McpToolExecutor.executeAsync() used to return the client future directly, so it ignored returnToolResultAttributes . By default the synchronous path dropped result attributes and the async path kept them. The fix applies the same policy on both paths. The detail I care about is the second half: filtering requires thenApply(...) , which creates a derived future, so the fix also forwards cancellation from that derived future to the client future. The bug and the fix are the same lesson. Every adapter that maps a future must forward cancel, or it silently breaks the chain. Working directory for stdio servers ( #6487 ). StdioMcpTransport.Builder.workingDirectory(Path) sets the server subprocess directory on start and on every restart. When it is not set, the subprocess inherits the host process directory, which was the only option before. Section 5 explains why this is a cancellation fix as much as a configuration fix. Read from the 1.22.0 source and the MCP cancellation spec. Highlighted cells changed in this release. Where the library's cancel ends Two boundaries did not move, and both are documented in the code. The first is in the AI Service async tool loop. Its Javadoc says that once the caller's cancellation future is cancelled, no further model call or tool execution starts and any in-flight model call is cancelled. It also says plainly that a tool execution that has already started is not interrupted . It runs to completion and its result is discarded, because Java cannot safely interrupt arbitrary tool code. So when the agent is cancelled through the AI Service, the long MCP call keeps running. The 1.22.0 propagation fix applies when something cancels the tool future itself: your code holding the executeToolAsync() future, a timeout, or an adapter that forwards cancel. The second is compensation. With compensateOnToolErrors(true) , the loop can roll back previously executed tools on cancellation, with CompensationReason.INVOCATION_CANCELLED . But @CompensateFor is limited to @Tool -annotated methods, and its Javadoc names MCP tools as unsupported. An MCP tool that committed half its work has no framework undo. Then the protocol takes over. In the MCP cancellation spec, a server that receives notifications/cancelled SHOULD stop processing and free resources, and MAY ignore the cancel if processing already completed or the request cannot be cancelled. The 2026-07-28 revision makes the transport rule explicit. Over Streamable HTTP, closing the response stream is the cancellation signal, and no notification is expected. Over stdio there is no per-request stream, so the client must send the notification. LangChain4j follows that split for the notification: shouldSendCancellationNotification() is true when the transport requires it or the protocol is legacy. A cancelled future is a fact about the caller. A stopped worker is a fact about the server. Do not report the second one because you observed the first. Fictional timings. The width of the hatched window is set by batch size, not by the client. The server has to listen If I write the MCP server, the cancel has to reach the work. The shape is the same whatever server SDK you use: register a cancellation hook for the request id, check a flag at every checkpoint, and cancel the running JDBC statement or HTTP request from the hook. I make each batch one transaction that also writes the operation id and its outbox rows. A batch that rolls back sends no email. A committed batch sends exactly what it committed. A retry with the same operation id continues instead of closing the same postings twice. SDK-neutral handler · checkpoint per batch CloseResult closeStalePostings(CloseCommand command, CancelToken cancel) throws SQLException { int closed = 0; while (!cancel.requested()) { try (Connection connection = dataSource.getConnection()) { connection.setAutoCommit(false); // Closes rows this operation id does not own yet, writes outbox rows, // and registers the running Statement with the token. int batch = postings.closeNextBatch( connection, command.operationId(), 500, cancel); if (batch == 0) { ledger.complete(connection, command.operationId(), closed); connection.commit(); return CloseResult.completed(closed); } ledger.recordBatch(connection, command.operationId(), batch); connection.commit(); closed += batch; } } ledger.cancelledAfter(command.operationId(), closed); return CloseResult.cancelledAfter(closed); } CancelToken , postings , and ledger are application types. Wire CancelToken to your server SDK's cancellation callback. The hook sets the flag and calls Statement.cancel() on the registered statement, which fails the batch so it rolls back. If the commit already finished, the cancel arrives too late for that batch, and the ledger is how you know. The host side records intent before it trusts any result. The ledger gets DISPATCHED before the call and CANCEL_REQUESTED when the user presses Stop. A CancellationException means the caller stopped waiting. It does not mean nothing happened, so the operation moves to reconciliation, which reads the server's ledger by operation id. host side · LangChain4j 1.22.0 · executeToolAsync is @Experimental Complet...