Repository navigation
HttpClientStreamableHttpTransport: a late 404 for an old session invalidates the current, healthy session and fails its in-flight requests #1270
Description
Version
io.modelcontextprotocol.sdk:mcp/mcp-core2.1.0 (tagv2.1.0,53574e2), reproduced.mainat54e8b61(2026-10-23): unchanged. None of the files below changed betweenv2.1.0andmain, so the line numbers are the same on both. The same code is in 2.0.0 / 2.0.1 (not tested).- Java 21, JDK
HttpClienttransport,McpAsyncClient(the sync client wraps it).
Description
statusError classifies an error response by the session ID that the request carried, and the comment there says so on purpose (HttpClientStreamableHttpTransport.java 630-647):
// Classify the response against the session id that this very request carried,
// rather than the one currently held by the session, which can be established
// concurrently. ...
Optional<String> sessionId = request.headers().firstValue(HttpHeaders.MCP_SESSION_ID);
if ((statusCode == NOT_FOUND || statusCode == BAD_REQUEST) && sessionId.isPresent()) {
return ResponseBodyHandlers.drainThenError(response.body(), this.maxResponseSize,
new McpTransportSessionNotFoundException(sessionId.get()));
}But handleException then replaces whatever session is active now. It does not check that this is the session the failed request was sent on (lines 259-271):
if (t instanceof McpTransportSessionNotFoundException) {
McpTransportSession<?> invalidSession = this.activeSession.getAndSet(createTransportSession());
logger.warn("Server does not recognize session {}. Invalidating.", invalidSession.sessionId());
invalidSession.close(); // sends DELETE for that session and disposes its exchanges
}
Consumer<Throwable> handler = this.exceptionHandler.get();
if (handler != null) {
handler.accept(t); // McpAsyncClient line 359: initializer::handleException
}The exception handler is LifecycleInitializer.handleException (lines 252-262). It closes the current initialization without any check either, and starts a new one:
DefaultInitialization previous = this.initializationRef.getAndSet(null);
if (previous != null) {
previous.close(); // -> McpClientSession.close() -> dismissPendingResponses()
}
this.withInitialization("re-initializing", result -> Mono.empty()).subscribe();McpClientSession.dismissPendingResponses (lines 127-133) then fails every pending request of that session with RuntimeException("MCP session with server terminated").
An exchange of the old session can still deliver its 404 after the client has moved on. When the client invalidates s1, DefaultMcpTransportSession.closeGracefully (lines 79-83) disposes the open exchanges of s1 only after the DELETE for s1 completes:
return Mono.from(this.onClose.apply(this.sessionId.get())) // DELETE
...
.then(Mono.fromRunnable(this.openConnections::dispose));So a 404 for s1 that arrives before the DELETE for s1 completes goes through handleException (line 593 for the first signal of a POST, 609 later on, 413 for the GET stream). By then a new session s2 may be active, and that 404 tears down s2:
- The client replaces
s2with a fresh session and sendsDELETEwithMcp-Session-Id: s2, so the server discards a session that was fine. - The exchanges of
s2are disposed, and every pending request ons2fails with "MCP session with server terminated". - A third session
s3is initialized.
The log names the wrong session: Server does not recognize session Optional[s2]. Invalidating., although the server answered 404 for s1. A 400 for s1 takes the same path.
Expected vs actual
- Expected: a 404/400 for a request that carried
s1invalidatess1only. Ifs1has already been replaced, only that request fails.s2and its in-flight requests are not affected, and no DELETE is sent fors2. - Actual: the late 404 for
s1invalidatess2. The client sendsDELETE s2, fails the in-flight request C ons2withRuntimeException: MCP session with server terminated, and re-initializes tos3.
Minimal reproduction
The project depends only on io.modelcontextprotocol.sdk:mcp:2.1.0 and JUnit 5. The server is a fake stateful Streamable HTTP server on the JDK com.sun.net.httpserver.HttpServer:
initializecreatess1,s2, ... and returns it inMcp-Session-Id.- GET returns 405 (no standalone SSE stream).
- A request whose session is unknown when it is answered gets 404.
- Tools
AandCare answered only when the test releases them. - The DELETE for an expired session is answered only at the end. This keeps the DELETE outstanding while A's 404 arrives (see above).
Sequence:
- Session
s1is established. - A on
s1is held by the server. - The server expires
s1, so B gets 404 and the client re-initializes tos2. - C on
s2is held by the server. - The server releases A with a 404 (for
s1).
The test passes on 2.1.0, which means it reproduces the bug. It does so on every run (8 of 8, about 0.4 s each).
@Test
void late404ForOldSessionTearsDownTheNewSession() throws Exception {
try (FakeServer server = new FakeServer()) {
McpAsyncClient client = McpClient
.async(HttpClientStreamableHttpTransport.builder(server.baseUri()).build())
.requestTimeout(Duration.ofSeconds(20))
.build();
try {
client.initialize().block(Duration.ofSeconds(5)); // session s1
call(client, "A"); // the server holds A, which carries s1
assertEquals("s1", server.arrived("A").get(5, SECONDS));
server.expire("s1");
CompletableFuture<?> b = call(client, "B"); // 404 -> client re-initializes
assertThrows(ExecutionException.class, () -> b.get(5, SECONDS));
CompletableFuture<?> c = call(client, "C"); // sent on the new session s2, held
assertEquals("s2", server.arrived("C").get(5, SECONDS));
server.release("A"); // late 404 for A, i.e. for s1
// Actual (2.1.0): C fails and the client itself deletes the healthy s2.
ExecutionException cFailure = assertThrows(ExecutionException.class, () -> c.get(5, SECONDS));
assertEquals("MCP session with server terminated", cFailure.getCause().getMessage());
server.deleted("s2").get(5, SECONDS);
}
finally {
client.close();
}
}
}Full test file with the fake server (StaleSessionNotFoundTest.java, ~190 lines)
package repro;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.modelcontextprotocol.client.McpAsyncClient;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@Timeout(30)
class StaleSessionNotFoundTest {
@Test
void late404ForOldSessionTearsDownTheNewSession() throws Exception {
try (FakeServer server = new FakeServer()) {
McpAsyncClient client = McpClient
.async(HttpClientStreamableHttpTransport.builder(server.baseUri()).build())
.requestTimeout(Duration.ofSeconds(20))
.build();
try {
client.initialize().block(Duration.ofSeconds(5)); // session s1
call(client, "A"); // the server holds A, which carries s1
assertEquals("s1", server.arrived("A").get(5, SECONDS));
server.expire("s1");
CompletableFuture<?> b = call(client, "B"); // 404 -> client re-initializes
assertThrows(ExecutionException.class, () -> b.get(5, SECONDS));
CompletableFuture<?> c = call(client, "C"); // sent on the new session s2, held
assertEquals("s2", server.arrived("C").get(5, SECONDS));
server.release("A"); // late 404 for A, i.e. for s1
// Actual (2.1.0): C fails and the client itself deletes the healthy s2.
ExecutionException cFailure = assertThrows(ExecutionException.class, () -> c.get(5, SECONDS));
assertEquals("MCP session with server terminated", cFailure.getCause().getMessage());
server.deleted("s2").get(5, SECONDS);
}
finally {
client.close();
}
}
}
private static CompletableFuture<McpSchema.CallToolResult> call(McpAsyncClient client, String tool) {
return client.callTool(McpSchema.CallToolRequest.builder(tool).build()).toFuture();
}
/**
* Minimal stateful Streamable HTTP server. Tools A and C are answered only once
* released; a request whose session is unknown at answer time gets 404. The DELETE
* for an expired session is answered only at close: while it is outstanding the
* client keeps the old session's exchanges (here A) subscribed.
*/
static final class FakeServer implements AutoCloseable {
private static final McpJsonMapper JSON = McpJsonDefaults.getMapper();
private final ExecutorService pool = Executors.newCachedThreadPool();
private final HttpServer http;
private final AtomicInteger sessionCounter = new AtomicInteger();
private final Set<String> live = ConcurrentHashMap.newKeySet();
private final Map<String, CountDownLatch> held = Map.of("A", new CountDownLatch(1), "C",
new CountDownLatch(1));
private final Map<String, CompletableFuture<String>> arrived = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<Void>> deleted = new ConcurrentHashMap<>();
private final CountDownLatch closing = new CountDownLatch(1);
FakeServer() throws IOException {
this.http = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
this.http.setExecutor(this.pool);
this.http.createContext("/mcp", this::handle);
this.http.start();
}
String baseUri() {
return "http://127.0.0.1:" + this.http.getAddress().getPort();
}
void expire(String sessionId) {
this.live.remove(sessionId);
}
void release(String tool) {
this.held.get(tool).countDown();
}
CompletableFuture<String> arrived(String tool) {
return this.arrived.computeIfAbsent(tool, k -> new CompletableFuture<>());
}
CompletableFuture<Void> deleted(String sessionId) {
return this.deleted.computeIfAbsent(sessionId, k -> new CompletableFuture<>());
}
private void handle(HttpExchange exchange) throws IOException {
try (exchange) {
String sessionId = exchange.getRequestHeaders().getFirst("Mcp-Session-Id");
switch (exchange.getRequestMethod()) {
case "GET" -> exchange.sendResponseHeaders(405, -1); // no standalone SSE
case "DELETE" -> {
if (!this.live.remove(sessionId)) {
this.closing.await(20, SECONDS);
}
deleted(sessionId).complete(null);
exchange.sendResponseHeaders(200, -1);
}
default -> post(exchange, sessionId);
}
}
catch (IOException | InterruptedException ex) {
// client went away or test is closing
}
}
private void post(HttpExchange exchange, String sessionId) throws IOException, InterruptedException {
var message = McpSchema.deserializeJsonRpcMessage(JSON,
new String(exchange.getRequestBody().readAllBytes(), UTF_8));
if (message instanceof McpSchema.JSONRPCRequest request && "initialize".equals(request.method())) {
String newSession = "s" + this.sessionCounter.incrementAndGet();
this.live.add(newSession);
exchange.getResponseHeaders().add("Mcp-Session-Id", newSession);
reply(exchange, request.id(), Map.of("protocolVersion", "2025-11-25", "capabilities",
Map.of("tools", Map.of()), "serverInfo", Map.of("name", "fake", "version", "1")));
return;
}
if (message instanceof McpSchema.JSONRPCRequest request && "tools/call".equals(request.method())) {
String tool = (String) ((Map<?, ?>) request.params()).get("name");
arrived(tool).complete(sessionId);
CountDownLatch latch = this.held.get(tool);
if (latch != null) {
latch.await(20, SECONDS);
}
if (this.live.contains(sessionId)) {
reply(exchange, request.id(), Map.of("content", List.of(Map.of("type", "text", "text", tool))));
return;
}
}
else if (this.live.contains(sessionId)) { // notifications/initialized
exchange.sendResponseHeaders(202, -1);
return;
}
exchange.sendResponseHeaders(404, -1);
}
private static void reply(HttpExchange exchange, Object id, Object result) throws IOException {
byte[] body = JSON.writeValueAsString(McpSchema.JSONRPCResponse.result(id, result)).getBytes(UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
}
@Override
public void close() {
this.closing.countDown();
this.held.values().forEach(CountDownLatch::countDown);
this.http.stop(0);
this.pool.shutdownNow();
}
}
}Impact
- Spurious failures for shared clients. One
McpAsyncClient/McpSyncClientis often shared by many callers, for example one client per server in an application. When the server expires a session or restarts, a single late 404 from the old session fails unrelated requests that were already running on the new session. It also deletes that session on the server. - Duplicate execution if callers retry on session loss. C had already reached the server on
s2and may have run there, or may still be running. An application that retries on "MCP session with server terminated" (or on session loss in general) sends C again ons3. A non-idempotent tool then runs twice. - From reading the code, not reproduced: several requests on
s1can get 404 at about the same time, for example after a server restart under concurrent load. Each 404 after the first then replaces the session that the previous one has just started to initialize. That can fail callers that are waiting for the re-initialization, and can leave sessions on the server that the client never learns about.
Suggested fix
Invalidate only the session that the failed request was sent on, and leave the rest alone when that session has already been replaced:
-
sendMessageandreconnectalready capturetransportSession(lines 479 and 337). Pass it tohandleException/handleExceptionSafely(call sites 413, 593, 609), and replace it with a compare-and-set:private void handleException(McpTransportSession<Disposable> failedSession, Throwable t) { if (t instanceof McpTransportSessionNotFoundException) { if (!this.activeSession.compareAndSet(failedSession, createTransportSession())) { logger.debug("Ignoring session-not-found for {}: no longer the active session", failedSession.sessionId()); return; // the failed request still fails; nothing else is touched } logger.warn("Server does not recognize session {}. Invalidating.", failedSession.sessionId()); failedSession.close(); } Consumer<Throwable> handler = this.exceptionHandler.get(); if (handler != null) { handler.accept(t); } }
Not forwarding the stale exception keeps
LifecycleInitializerfrom closing the current initialization. The request that got the 404 still fails, throughdeliveredSink.error(...), or it has already been dismissed together with its old session. -
Optionally,
McpTransportSessionNotFoundExceptioncould expose the session ID it already receives (getSessionId()). ThenLifecycleInitializer.handleException, and transports outside this repository, can also ignore a session that is no longer current. -
Add a regression test like the one above, with the assertions inverted: C completes, no DELETE is sent for
s2, and there are only twoinitializerequests.
I checked step 1 locally. I applied it to a copy of the 2.1.0 class, shadowing it on the test classpath, and ran the inverted test. The test passes with the change, and fails on stock 2.1.0 with MCP session with server terminated.
Side note: Spring AI's WebClientStreamableHttpTransport (spring-projects/spring-ai main b90fb6f, mcp/transport/mcp-spring-webflux/.../WebClientStreamableHttpTransport.java lines 197-212) uses the same unconditional activeSession.getAndSet(createTransportSession()). I have not tested it.
Activity
Kehrlann commentedon Oct 24, 2026
Given the work in progress for the new, modern protocol, this low priority.
JunyangLian commentedon Oct 26, 2026
I minimized the invalidation path into a unit test to confirm the report on
main(54e8b61). It fails against the current code:Failure output:
What I think happens:
statusError()correctly tags the exception with the session id carried by that request.handleException()then invalidatesactiveSessionunconditionally whenever it seesMcpTransportSessionNotFoundException, without comparing the exception's session id toactiveSession.get().sessionId().McpTransportSessionNotFoundExceptiondoesn't expose the id (it only appears in the message), so a fix likely needs a small accessor or a field sohandleException()can tell whether the failed request still belongs to the current session.I'm not proposing a fix here (Kehrlann mentioned the modern-protocol work takes priority); this is just a reproducible test case if it becomes actionable.
field/invokeabove are thin reflection helpers overactiveSessionandhandleException, omitted for brevity.Ben-Home commentedon Oct 26, 2026