Skip to content

HttpClientStreamableHttpTransport: a late 404 for an old session invalidates the current, healthy session and fails its in-flight requests #1270

Open

Description

@starryeye

Version

  • io.modelcontextprotocol.sdk:mcp / mcp-core 2.1.0 (tag v2.1.0, 53574e2), reproduced.
  • main at 54e8b61 (2026-10-23): unchanged. None of the files below changed between v2.1.0 and main, 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 HttpClient transport, 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:

  1. The client replaces s2 with a fresh session and sends DELETE with Mcp-Session-Id: s2, so the server discards a session that was fine.
  2. The exchanges of s2 are disposed, and every pending request on s2 fails with "MCP session with server terminated".
  3. A third session s3 is 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 s1 invalidates s1 only. If s1 has already been replaced, only that request fails. s2 and its in-flight requests are not affected, and no DELETE is sent for s2.
  • Actual: the late 404 for s1 invalidates s2. The client sends DELETE s2, fails the in-flight request C on s2 with RuntimeException: MCP session with server terminated, and re-initializes to s3.

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:

  • initialize creates s1, s2, ... and returns it in Mcp-Session-Id.
  • GET returns 405 (no standalone SSE stream).
  • A request whose session is unknown when it is answered gets 404.
  • Tools A and C are 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:

  1. Session s1 is established.
  2. A on s1 is held by the server.
  3. The server expires s1, so B gets 404 and the client re-initializes to s2.
  4. C on s2 is held by the server.
  5. 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/McpSyncClient is 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 s2 and 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 on s3. A non-idempotent tool then runs twice.
  • From reading the code, not reproduced: several requests on s1 can 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:

  1. sendMessage and reconnect already capture transportSession (lines 479 and 337). Pass it to handleException/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 LifecycleInitializer from closing the current initialization. The request that got the 404 still fails, through deliveredSink.error(...), or it has already been dismissed together with its old session.

  2. Optionally, McpTransportSessionNotFoundException could expose the session ID it already receives (getSessionId()). Then LifecycleInitializer.handleException, and transports outside this repository, can also ignore a session that is no longer current.

  3. Add a regression test like the one above, with the assertions inverted: C completes, no DELETE is sent for s2, and there are only two initialize requests.

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

  1. added theissue type on Oct 24, 2026
  2. added
    bugSomething isn't working
    P3Nice to haves, rare edge cases
    legacyIssues about versions <= 2026-07-28
    on Oct 24, 2026
  3. Kehrlann commented on Oct 24, 2026

    @Kehrlann
    Contributor

    Given the work in progress for the new, modern protocol, this low priority.

  4. JunyangLian commented on Oct 26, 2026

    @JunyangLian

    I minimized the invalidation path into a unit test to confirm the report on main (54e8b61). It fails against the current code:

    // mcp-core/src/test/java/io/modelcontextprotocol/client/transport/LateSessionNotFoundReproTest.java
    @Test
    void lateNotFoundForOldSessionKeepsCurrentSession() throws Exception {
        var transport = HttpClientStreamableHttpTransport.builder("http://localhost:1")
            .jsonMapper(new GsonMcpJsonMapper())
            .build();
    
        @SuppressWarnings("unchecked")
        AtomicReference<McpTransportSession<Disposable>> activeSession =
            (AtomicReference<McpTransportSession<Disposable>>) field(transport, "activeSession");
    
        var current = new DefaultMcpTransportSession(id -> Mono.empty());
        current.markInitialized("current-session");
        activeSession.set(current);
    
        invoke(transport, "handleException", new Class<?>[] { Throwable.class },
            new McpTransportSessionNotFoundException("stale-session"));
    
        assertThat(activeSession.get().sessionId()).contains("current-session");
    }

    Failure output:

    [ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
    java.lang.AssertionError:
    Expecting Optional to contain: "current-session"
    

    What I think happens:

    • statusError() correctly tags the exception with the session id carried by that request.
    • handleException() then invalidates activeSession unconditionally whenever it sees McpTransportSessionNotFoundException, without comparing the exception's session id to activeSession.get().sessionId().
    • McpTransportSessionNotFoundException doesn't expose the id (it only appears in the message), so a fix likely needs a small accessor or a field so handleException() 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/invoke above are thin reflection helpers over activeSession and handleException, omitted for brevity.

  5. Ben-Home commented on Oct 26, 2026

    @Ben-Home
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge casesarea/clientbugSomething isn't workinglegacyIssues about versions <= 2026-07-28

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions