fix: improve streamable HTTP session reinitialization (#459)

Implements the MCP spec guidelines for streamable HTTP (re)initialization:
- Server MAY terminate session and MUST respond with HTTP 404 for terminated session IDs
- Client MUST start new session when receiving HTTP 404 for requests with session ID

Changes:
- Replace generic McpError with McpTransportException for transport-layer errors
- Only throw McpTransportSessionNotFoundException when session ID is present in request
  (per spec: 404 with session ID means session terminated, without means general error)
- Enhance error messages with more context (status codes, response events)
- Use RuntimeException for non-transport specific SSE endpoint failures
- Ensure consistent error handling across HTTP client transports

- Improve error handling with standard Java exceptions.
  Replace generic McpError with appropriate standard exceptions:
  - Use IllegalArgumentException for invalid input parameters
  - Use IllegalStateException for state-related issues
  - Use RuntimeException wrapper for initialization failures
  - Use McpError.builder() with proper error codes for protocol errors

Fixes #459

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-08-07 18:35:50 +01:00
commit ea50838d06
25 changed files with 955 additions and 90 deletions

View file

@ -31,6 +31,7 @@ import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.McpTransportSession;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import io.modelcontextprotocol.spec.McpTransportStream;
@ -70,6 +71,8 @@ import reactor.util.function.Tuples;
*/
public class WebClientStreamableHttpTransport implements McpClientTransport {
private static final String MISSING_SESSION_ID = "[missing_session_id]";
private static final Logger logger = LoggerFactory.getLogger(WebClientStreamableHttpTransport.class);
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_03_26;
@ -221,8 +224,13 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
return Flux.empty();
}
else if (isNotFound(response)) {
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
return mcpSessionNotFoundError(sessionIdRepresentation);
if (transportSession.sessionId().isPresent()) {
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
return mcpSessionNotFoundError(sessionIdRepresentation);
}
else {
return this.extractError(response, MISSING_SESSION_ID);
}
}
else {
return response.<McpSchema.JSONRPCMessage>createError().doOnError(e -> {
@ -318,10 +326,10 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
}
}
else {
if (isNotFound(response)) {
if (isNotFound(response) && !sessionRepresentation.equals(MISSING_SESSION_ID)) {
return mcpSessionNotFoundError(sessionRepresentation);
}
return extractError(response, sessionRepresentation);
return this.extractError(response, sessionRepresentation);
}
})
.flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage)))
@ -362,10 +370,10 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
McpSchema.JSONRPCResponse.class);
jsonRpcError = jsonRpcResponse.error();
toPropagate = jsonRpcError != null ? new McpError(jsonRpcError)
: new McpError("Can't parse the jsonResponse " + jsonRpcResponse);
: new McpTransportException("Can't parse the jsonResponse " + jsonRpcResponse);
}
catch (IOException ex) {
toPropagate = new RuntimeException("Sending request failed", e);
toPropagate = new McpTransportException("Sending request failed, " + e.getMessage(), e);
logger.debug("Received content together with {} HTTP code response: {}", response.statusCode(), body);
}
@ -374,7 +382,11 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
// invalidate the session
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
if (responseException.getStatusCode().isSameCodeAs(HttpStatus.BAD_REQUEST)) {
return Mono.error(new McpTransportSessionNotFoundException(sessionRepresentation, toPropagate));
if (!sessionRepresentation.equals(MISSING_SESSION_ID)) {
return Mono.error(new McpTransportSessionNotFoundException(sessionRepresentation, toPropagate));
}
return Mono.error(new McpTransportException("Received 400 BAD REQUEST for session "
+ sessionRepresentation + ". " + toPropagate.getMessage(), toPropagate));
}
return Mono.error(toPropagate);
}).flux();
@ -403,7 +415,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
}
private static String sessionIdOrPlaceholder(McpTransportSession<?> transportSession) {
return transportSession.sessionId().orElse("[missing_session_id]");
return transportSession.sessionId().orElse(MISSING_SESSION_ID);
}
private Flux<McpSchema.JSONRPCMessage> directResponseFlux(McpSchema.JSONRPCMessage sentMessage,
@ -421,8 +433,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
}
}
catch (IOException e) {
// TODO: this should be a McpTransportError
s.error(e);
s.error(new McpTransportException(e));
}
}).flatMapIterable(Function.identity());
}
@ -449,7 +460,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
return Tuples.of(Optional.ofNullable(event.id()), List.of(message));
}
catch (IOException ioException) {
throw new McpError("Error parsing JSON-RPC message: " + event.data());
throw new McpTransportException("Error parsing JSON-RPC message: " + event.data(), ioException);
}
}
else {

View file

@ -14,7 +14,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.spec.ProtocolVersions;
@ -197,8 +196,6 @@ public class WebFluxSseClientTransport implements McpClientTransport {
* @param handler a function that processes incoming JSON-RPC messages and returns
* responses
* @return a Mono that completes when the connection is fully established
* @throws McpError if there's an error processing SSE events or if an unrecognized
* event type is received
*/
@Override
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
@ -215,7 +212,7 @@ public class WebFluxSseClientTransport implements McpClientTransport {
else {
// TODO: clarify with the spec if multiple events can be
// received
s.error(new McpError("Failed to handle SSE endpoint event"));
s.error(new RuntimeException("Failed to handle SSE endpoint event"));
}
}
else if (MESSAGE_EVENT_TYPE.equals(event.event())) {

View file

@ -8,6 +8,7 @@ import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.client.WebClient;
@ -26,6 +27,7 @@ import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvide
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
@Timeout(15)
class WebFluxSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();

View file

@ -8,6 +8,7 @@ import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.client.WebClient;
@ -26,6 +27,7 @@ import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
@Timeout(15)
class WebFluxStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();

View file

@ -8,6 +8,7 @@ import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.client.WebClient;
@ -26,6 +27,7 @@ import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransport
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
@Timeout(15)
class WebFluxStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();

View file

@ -0,0 +1,404 @@
/*
* Copyright 2025-2025 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.springframework.web.reactive.function.client.WebClient;
import com.sun.net.httpserver.HttpServer;
import io.modelcontextprotocol.server.TestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import io.modelcontextprotocol.spec.ProtocolVersions;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
/**
* Tests for error handling in WebClientStreamableHttpTransport. Addresses concurrency
* issues with proper Reactor patterns.
*
* @author Christian Tzolov
*/
@Timeout(15)
public class WebClientStreamableHttpTransportErrorHandlingTest {
private static final int PORT = TestUtil.findAvailablePort();
private static final String HOST = "http://localhost:" + PORT;
private HttpServer server;
private AtomicReference<Integer> serverResponseStatus = new AtomicReference<>(200);
private AtomicReference<String> currentServerSessionId = new AtomicReference<>(null);
private AtomicReference<String> lastReceivedSessionId = new AtomicReference<>(null);
private McpClientTransport transport;
// Initialize latches for proper request synchronization
CountDownLatch firstRequestLatch;
CountDownLatch secondRequestLatch;
CountDownLatch getRequestLatch;
@BeforeEach
void startServer() throws IOException {
// Initialize latches for proper synchronization
firstRequestLatch = new CountDownLatch(1);
secondRequestLatch = new CountDownLatch(1);
getRequestLatch = new CountDownLatch(1);
server = HttpServer.create(new InetSocketAddress(PORT), 0);
// Configure the /mcp endpoint with dynamic response
server.createContext("/mcp", exchange -> {
String method = exchange.getRequestMethod();
if ("GET".equals(method)) {
// This is the SSE connection attempt after session establishment
getRequestLatch.countDown();
// Return 405 Method Not Allowed to indicate SSE not supported
exchange.sendResponseHeaders(405, 0);
exchange.close();
return;
}
String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
lastReceivedSessionId.set(requestSessionId);
int status = serverResponseStatus.get();
// Track which request this is
if (firstRequestLatch.getCount() > 0) {
// // First request - should have no session ID
firstRequestLatch.countDown();
}
else if (secondRequestLatch.getCount() > 0) {
// Second request - should have session ID
secondRequestLatch.countDown();
}
exchange.getResponseHeaders().set("Content-Type", "application/json");
// Don't include session ID in 404 and 400 responses - the implementation
// checks if the transport has a session stored locally
String responseSessionId = currentServerSessionId.get();
if (responseSessionId != null && status == 200) {
exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
}
if (status == 200) {
String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
exchange.sendResponseHeaders(200, response.length());
exchange.getResponseBody().write(response.getBytes());
}
else {
exchange.sendResponseHeaders(status, 0);
}
exchange.close();
});
server.setExecutor(null);
server.start();
transport = WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(HOST)).build();
}
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
/**
* Test that 404 response WITHOUT session ID throws McpTransportException (not
* SessionNotFoundException)
*/
@Test
void test404WithoutSessionId() {
serverResponseStatus.set(404);
currentServerSessionId.set(null); // No session ID in response
var testMessage = createTestMessage();
StepVerifier.create(transport.sendMessage(testMessage))
.expectErrorMatches(throwable -> throwable instanceof McpTransportException
&& throwable.getMessage().contains("Not Found") && throwable.getMessage().contains("404")
&& !(throwable instanceof McpTransportSessionNotFoundException))
.verify(Duration.ofSeconds(5));
}
/**
* Test that 404 response WITH session ID throws McpTransportSessionNotFoundException
* Fixed version using proper async coordination
*/
@Test
void test404WithSessionId() throws InterruptedException {
// First establish a session
serverResponseStatus.set(200);
currentServerSessionId.set("test-session-123");
// Set up exception handler to verify session invalidation
@SuppressWarnings("unchecked")
Consumer<Throwable> exceptionHandler = mock(Consumer.class);
transport.setExceptionHandler(exceptionHandler);
// Connect with handler
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
// Send initial message to establish session
var testMessage = createTestMessage();
// Send first message to establish session
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
// Wait for first request to complete
assertThat(firstRequestLatch.await(5, TimeUnit.SECONDS)).isTrue();
// Wait for the GET request (SSE connection attempt) to complete
assertThat(getRequestLatch.await(5, TimeUnit.SECONDS)).isTrue();
// Now return 404 for next request
serverResponseStatus.set(404);
// Use delaySubscription to ensure session is fully processed before next
// request
StepVerifier.create(Mono.delay(Duration.ofMillis(200)).then(transport.sendMessage(testMessage)))
.expectError(McpTransportSessionNotFoundException.class)
.verify(Duration.ofSeconds(5));
// Wait for second request to be made
assertThat(secondRequestLatch.await(5, TimeUnit.SECONDS)).isTrue();
// Verify the second request included the session ID
assertThat(lastReceivedSessionId.get()).isEqualTo("test-session-123");
// Verify exception handler was called with SessionNotFoundException using
// timeout
verify(exceptionHandler, timeout(5000)).accept(any(McpTransportSessionNotFoundException.class));
}
/**
* Test that 400 response WITHOUT session ID throws McpTransportException (not
* SessionNotFoundException)
*/
@Test
void test400WithoutSessionId() {
serverResponseStatus.set(400);
currentServerSessionId.set(null); // No session ID
var testMessage = createTestMessage();
StepVerifier.create(transport.sendMessage(testMessage))
.expectErrorMatches(throwable -> throwable instanceof McpTransportException
&& throwable.getMessage().contains("Bad Request") && throwable.getMessage().contains("400")
&& !(throwable instanceof McpTransportSessionNotFoundException))
.verify(Duration.ofSeconds(5));
}
/**
* Test that 400 response WITH session ID throws McpTransportSessionNotFoundException
* Fixed version using proper async coordination
*/
@Test
void test400WithSessionId() throws InterruptedException {
// First establish a session
serverResponseStatus.set(200);
currentServerSessionId.set("test-session-456");
// Set up exception handler
@SuppressWarnings("unchecked")
Consumer<Throwable> exceptionHandler = mock(Consumer.class);
transport.setExceptionHandler(exceptionHandler);
// Connect with handler
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
// Send initial message to establish session
var testMessage = createTestMessage();
// Send first message to establish session
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
// Wait for first request to complete
boolean firstCompleted = firstRequestLatch.await(5, TimeUnit.SECONDS);
assertThat(firstCompleted).isTrue();
// Wait for the GET request (SSE connection attempt) to complete
boolean getCompleted = getRequestLatch.await(5, TimeUnit.SECONDS);
assertThat(getCompleted).isTrue();
// Now return 400 for next request (simulating unknown session ID)
serverResponseStatus.set(400);
// Use delaySubscription to ensure session is fully processed before next
// request
StepVerifier.create(Mono.delay(Duration.ofMillis(200)).then(transport.sendMessage(testMessage)))
.expectError(McpTransportSessionNotFoundException.class)
.verify(Duration.ofSeconds(5));
// Wait for second request to be made
boolean secondCompleted = secondRequestLatch.await(5, TimeUnit.SECONDS);
assertThat(secondCompleted).isTrue();
// Verify the second request included the session ID
assertThat(lastReceivedSessionId.get()).isEqualTo("test-session-456");
// Verify exception handler was called with timeout
verify(exceptionHandler, timeout(5000)).accept(any(McpTransportSessionNotFoundException.class));
}
/**
* Test session recovery after SessionNotFoundException Fixed version using reactive
* patterns and proper synchronization
*/
@Test
void testSessionRecoveryAfter404() {
// First establish a session
serverResponseStatus.set(200);
currentServerSessionId.set("session-1");
// Send initial message to establish session
var testMessage = createTestMessage();
// Use Mono.defer to ensure proper sequencing
Mono<Void> establishSession = transport.sendMessage(testMessage).then(Mono.defer(() -> {
// Simulate session loss - return 404
serverResponseStatus.set(404);
return transport.sendMessage(testMessage).onErrorResume(McpTransportSessionNotFoundException.class, e -> {
// Expected error, continue with recovery
return Mono.empty();
});
})).then(Mono.defer(() -> {
// Now server is back with new session
serverResponseStatus.set(200);
currentServerSessionId.set("session-2");
lastReceivedSessionId.set(null); // Reset to verify new session
// Should be able to establish new session
return transport.sendMessage(testMessage);
})).then(Mono.defer(() -> {
// Verify no session ID was sent (since old session was invalidated)
assertThat(lastReceivedSessionId.get()).isNull();
// Next request should use the new session ID
return transport.sendMessage(testMessage);
})).doOnSuccess(v -> {
// Session ID should now be sent with requests
assertThat(lastReceivedSessionId.get()).isEqualTo("session-2");
});
StepVerifier.create(establishSession).verifyComplete();
}
/**
* Test that reconnect (GET request) also properly handles 404/400 errors Fixed
* version with proper async handling
*/
@Test
void testReconnectErrorHandling() throws InterruptedException {
// Initialize latch for SSE connection
CountDownLatch sseConnectionLatch = new CountDownLatch(1);
// Set up SSE endpoint for GET requests
server.createContext("/mcp-sse", exchange -> {
String method = exchange.getRequestMethod();
String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
if ("GET".equals(method)) {
sseConnectionLatch.countDown();
int status = serverResponseStatus.get();
if (status == 404 && requestSessionId != null) {
// 404 with session ID - should trigger SessionNotFoundException
exchange.sendResponseHeaders(404, 0);
}
else if (status == 404) {
// 404 without session ID - should trigger McpTransportException
exchange.sendResponseHeaders(404, 0);
}
else {
// Normal SSE response
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.sendResponseHeaders(200, 0);
// Send a test SSE event
String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n";
exchange.getResponseBody().write(sseData.getBytes());
}
}
else {
// POST request handling
exchange.getResponseHeaders().set("Content-Type", "application/json");
String responseSessionId = currentServerSessionId.get();
if (responseSessionId != null) {
exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
}
String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
exchange.sendResponseHeaders(200, response.length());
exchange.getResponseBody().write(response.getBytes());
}
exchange.close();
});
// Test with session ID - should get SessionNotFoundException
serverResponseStatus.set(200);
currentServerSessionId.set("sse-session-1");
var transport = WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(HOST))
.endpoint("/mcp-sse")
.openConnectionOnStartup(true) // This will trigger GET request on connect
.build();
// First connect successfully
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
// Wait for SSE connection to be established
boolean connected = sseConnectionLatch.await(5, TimeUnit.SECONDS);
assertThat(connected).isTrue();
// Send message to establish session
var testMessage = createTestMessage();
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
// Clean up
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
private McpSchema.JSONRPCRequest createTestMessage() {
var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
McpSchema.ClientCapabilities.builder().roots(true).build(),
new McpSchema.Implementation("Test Client", "1.0.0"));
return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id",
initializeRequest);
}
}

View file

@ -11,6 +11,7 @@ import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@ -29,6 +30,7 @@ import io.modelcontextprotocol.server.McpServer.SingleSessionSyncSpecification;
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
import reactor.core.scheduler.Schedulers;
@Timeout(15)
class WebMvcSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();

View file

@ -11,6 +11,7 @@ import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@ -29,6 +30,7 @@ import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport;
import reactor.core.scheduler.Schedulers;
@Timeout(15)
class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();

View file

@ -11,6 +11,7 @@ import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Timeout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@ -29,6 +30,7 @@ import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider;
import reactor.core.scheduler.Schedulers;
@Timeout(15)
class WebMvcStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();

View file

@ -486,7 +486,8 @@ public abstract class AbstractMcpAsyncClientTests {
void testAddRootWithNullValue() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.addRoot(null))
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null"))
.consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Root must not be null"))
.verify();
});
}
@ -505,7 +506,7 @@ public abstract class AbstractMcpAsyncClientTests {
void testRemoveNonExistentRoot() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri"))
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
.consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
.hasMessage("Root with uri 'nonexistent-uri' not found"))
.verify();
});

View file

@ -289,9 +289,7 @@ class LifecycleInitializer {
return initializationJob.map(initializeResult -> this.initializationRef.get())
.timeout(this.initializationTimeout)
.onErrorResume(ex -> {
logger.warn("Failed to initialize", ex);
return Mono.error(
new McpError("Client failed to initialize " + actionName + " due to: " + ex.getMessage()));
return Mono.error(new RuntimeException("Client failed to initialize " + actionName, ex));
})
.flatMap(operation);
});
@ -316,8 +314,10 @@ class LifecycleInitializer {
initializeResult.instructions());
if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
return Mono.error(new McpError(
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
return Mono.error(McpError.builder(-32602)
.message("Unsupported protocol version")
.data("Unsupported protocol version from the server: " + initializeResult.protocolVersion())
.build());
}
return mcpClientSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)

View file

@ -190,7 +190,8 @@ public class McpAsyncClient {
// Sampling Handler
if (this.clientCapabilities.sampling() != null) {
if (features.samplingHandler() == null) {
throw new McpError("Sampling handler must not be null when client capabilities include sampling");
throw new IllegalArgumentException(
"Sampling handler must not be null when client capabilities include sampling");
}
this.samplingHandler = features.samplingHandler();
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
@ -199,7 +200,8 @@ public class McpAsyncClient {
// Elicitation Handler
if (this.clientCapabilities.elicitation() != null) {
if (features.elicitationHandler() == null) {
throw new McpError("Elicitation handler must not be null when client capabilities include elicitation");
throw new IllegalArgumentException(
"Elicitation handler must not be null when client capabilities include elicitation");
}
this.elicitationHandler = features.elicitationHandler();
requestHandlers.put(McpSchema.METHOD_ELICITATION_CREATE, elicitationCreateHandler());
@ -413,15 +415,15 @@ public class McpAsyncClient {
public Mono<Void> addRoot(Root root) {
if (root == null) {
return Mono.error(new McpError("Root must not be null"));
return Mono.error(new IllegalArgumentException("Root must not be null"));
}
if (this.clientCapabilities.roots() == null) {
return Mono.error(new McpError("Client must be configured with roots capabilities"));
return Mono.error(new IllegalStateException("Client must be configured with roots capabilities"));
}
if (this.roots.containsKey(root.uri())) {
return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
return Mono.error(new IllegalStateException("Root with uri '" + root.uri() + "' already exists"));
}
this.roots.put(root.uri(), root);
@ -447,11 +449,11 @@ public class McpAsyncClient {
public Mono<Void> removeRoot(String rootUri) {
if (rootUri == null) {
return Mono.error(new McpError("Root uri must not be null"));
return Mono.error(new IllegalArgumentException("Root uri must not be null"));
}
if (this.clientCapabilities.roots() == null) {
return Mono.error(new McpError("Client must be configured with roots capabilities"));
return Mono.error(new IllegalStateException("Client must be configured with roots capabilities"));
}
Root removed = this.roots.remove(rootUri);
@ -469,7 +471,7 @@ public class McpAsyncClient {
}
return Mono.empty();
}
return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
return Mono.error(new IllegalStateException("Root with uri '" + rootUri + "' not found"));
}
/**
@ -540,7 +542,7 @@ public class McpAsyncClient {
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
return this.initializer.withIntitialization("calling tools", init -> {
if (init.initializeResult().capabilities().tools() == null) {
return Mono.error(new McpError("Server does not provide tools capability"));
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
}
return init.mcpSession()
.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
@ -569,7 +571,7 @@ public class McpAsyncClient {
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
return this.initializer.withIntitialization("listing tools", init -> {
if (init.initializeResult().capabilities().tools() == null) {
return Mono.error(new McpError("Server does not provide tools capability"));
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
}
return init.mcpSession()
.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
@ -633,7 +635,7 @@ public class McpAsyncClient {
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
return this.initializer.withIntitialization("listing resources", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new McpError("Server does not provide the resources capability"));
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
@ -665,7 +667,7 @@ public class McpAsyncClient {
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
return this.initializer.withIntitialization("reading resources", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new McpError("Server does not provide the resources capability"));
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF);
@ -703,7 +705,7 @@ public class McpAsyncClient {
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
return this.initializer.withIntitialization("listing resource templates", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new McpError("Server does not provide the resources capability"));
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor),
@ -863,7 +865,7 @@ public class McpAsyncClient {
*/
public Mono<Void> setLoggingLevel(LoggingLevel loggingLevel) {
if (loggingLevel == null) {
return Mono.error(new McpError("Logging level must not be null"));
return Mono.error(new IllegalArgumentException("Logging level must not be null"));
}
return this.initializer.withIntitialization("setting logging level", init -> {

View file

@ -24,10 +24,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import reactor.core.Disposable;
@ -431,7 +431,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return Flux.empty(); // No further processing needed
}
else {
sink.error(new McpError("Failed to handle SSE endpoint event"));
sink.error(new RuntimeException("Failed to handle SSE endpoint event"));
}
}
else if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
@ -446,8 +446,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
}
}
catch (IOException e) {
logger.error("Error processing SSE event", e);
sink.error(new McpError("Error processing SSE event"));
sink.error(new McpTransportException("Error processing SSE event", e));
}
}
return Flux.<McpSchema.JSONRPCMessage>error(
@ -520,8 +519,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
return Mono.just(objectMapper.writeValueAsString(message));
}
catch (IOException e) {
// TODO: why McpError and not RuntimeException?
return Mono.error(new McpError("Failed to serialize message"));
return Mono.error(new McpTransportException("Failed to serialize message", e));
}
});
}

View file

@ -30,8 +30,8 @@ import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.McpTransportSession;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import io.modelcontextprotocol.spec.McpTransportStream;
@ -288,9 +288,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
}
catch (IOException ioException) {
return Flux.<McpSchema.JSONRPCMessage>error(
new McpError("Error parsing JSON-RPC message: "
+ responseEvent.sseEvent().data()));
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Error parsing JSON-RPC message: " + responseEvent, ioException));
}
}
else {
@ -304,19 +303,39 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.empty();
}
else if (statusCode == NOT_FOUND) {
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionIdRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
if (transportSession != null && transportSession.sessionId().isPresent()) {
// only if the request was sent with a session id
// and the response is 404, we consider it a
// session not found error.
logger.debug("Session not found for session ID: {}",
transportSession.sessionId().get());
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionIdRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
}
return Flux.<McpSchema.JSONRPCMessage>error(
new McpTransportException("Server Not Found. Status code:" + statusCode
+ ", response-event:" + responseEvent));
}
else if (statusCode == BAD_REQUEST) {
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionIdRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
if (transportSession != null && transportSession.sessionId().isPresent()) {
// only if the request was sent with a session id
// and thre response is 404, we consider it a
// session not found error.
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionIdRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
}
return Flux.<McpSchema.JSONRPCMessage>error(
new McpTransportException("Bad Request. Status code:" + statusCode
+ ", response-event:" + responseEvent));
}
return Flux.<McpSchema.JSONRPCMessage>error(new McpError(
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Received unrecognized SSE event type: " + responseEvent.sseEvent().event()));
}).<McpSchema
.JSONRPCMessage>flatMap(
@ -468,8 +487,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
}
catch (IOException ioException) {
return Flux.<McpSchema.JSONRPCMessage>error(
new McpError("Error parsing JSON-RPC message: " + sseEvent.data()));
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Error parsing JSON-RPC message: " + responseEvent, ioException));
}
});
}
@ -485,8 +504,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Mono.just(McpSchema.deserializeJsonRpcMessage(objectMapper, data));
}
catch (IOException e) {
// TODO: this should be a McpTransportError
return Mono.error(e);
return Mono.error(new McpTransportException(
"Error deserializing JSON-RPC message: " + responseEvent, e));
}
}
logger.warn("Unknown media type {} returned for POST in session {}", contentType,
@ -496,18 +515,32 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
new RuntimeException("Unknown media type returned: " + contentType));
}
else if (statusCode == NOT_FOUND) {
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
if (transportSession != null && transportSession.sessionId().isPresent()) {
// only if the request was sent with a session id and the
// response is 404, we consider it a session not found error.
logger.debug("Session not found for session ID: {}", transportSession.sessionId().get());
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
}
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Server Not Found. Status code:" + statusCode + ", response-event:" + responseEvent));
}
// Some implementations can return 400 when presented with a
// session id that it doesn't know about, so we will
// invalidate the session
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
else if (statusCode == BAD_REQUEST) {
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
// Some implementations can return 400 when presented with a
// session id that it doesn't know about, so we will
// invalidate the session
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
if (transportSession != null && transportSession.sessionId().isPresent()) {
// only if the request was sent with a session id and the
// response is 404, we consider it a session not found error.
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
"Session not found for session ID: " + sessionRepresentation);
return Flux.<McpSchema.JSONRPCMessage>error(exception);
}
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
}
return Flux.<McpSchema.JSONRPCMessage>error(

View file

@ -15,7 +15,7 @@ import org.reactivestreams.Subscription;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpTransportException;
import reactor.core.publisher.BaseSubscriber;
import reactor.core.publisher.FluxSink;
@ -178,8 +178,7 @@ class ResponseSubscribers {
}
else {
// If the response is not successful, emit an error
// TODO: This should be a McpTransportError
this.sink.error(new McpError(
this.sink.error(new McpTransportException(
"Invalid SSE response. Status code: " + this.responseInfo.statusCode() + " Line: " + line));
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
package io.modelcontextprotocol.spec;
/**
* Exception thrown when there is an issue with the transport layer of the Model Context
* Protocol (MCP).
*
* <p>
* This exception is used to indicate errors that occur during communication between the
* MCP client and server, such as connection failures, protocol violations, or unexpected
* responses.
*
* @author Christian Tzolov
*/
public class McpTransportException extends RuntimeException {
private static final long serialVersionUID = 1L;
public McpTransportException(String message) {
super(message);
}
public McpTransportException(String message, Throwable cause) {
super(message, cause);
}
public McpTransportException(Throwable cause) {
super(cause);
}
public McpTransportException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}

View file

@ -487,7 +487,8 @@ public abstract class AbstractMcpAsyncClientTests {
void testAddRootWithNullValue() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.addRoot(null))
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null"))
.consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Root must not be null"))
.verify();
});
}
@ -506,7 +507,7 @@ public abstract class AbstractMcpAsyncClientTests {
void testRemoveNonExistentRoot() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri"))
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
.consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
.hasMessage("Root with uri 'nonexistent-uri' not found"))
.verify();
});

View file

@ -16,7 +16,6 @@ import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import io.modelcontextprotocol.spec.McpClientSession;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import reactor.core.publisher.Mono;
@ -154,7 +153,7 @@ class LifecycleInitializerTests {
.thenReturn(Mono.just(unsupportedResult));
StepVerifier.create(initializer.withIntitialization("test", init -> Mono.just(init.initializeResult())))
.expectError(McpError.class)
.expectError(RuntimeException.class)
.verify();
verify(mockClientSession, never()).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_INITIALIZED), any());
@ -178,7 +177,7 @@ class LifecycleInitializerTests {
init -> Mono.just(init.initializeResult())), () -> virtualTimeScheduler, Long.MAX_VALUE)
.expectSubscription()
.expectNoEvent(INITIALIZE_TIMEOUT)
.expectError(McpError.class)
.expectError(RuntimeException.class)
.verify();
}
@ -234,7 +233,7 @@ class LifecycleInitializerTests {
.thenReturn(Mono.error(new RuntimeException("Connection failed")));
StepVerifier.create(initializer.withIntitialization("test", init -> Mono.just(init.initializeResult())))
.expectError(McpError.class)
.expectError(RuntimeException.class)
.verify();
assertThat(initializer.isInitialized()).isFalse();

View file

@ -13,7 +13,6 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.MockMcpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
@ -373,7 +372,7 @@ class McpAsyncClientResponseHandlerTests {
// Create client with sampling capability but null handler
assertThatThrownBy(
() -> McpClient.async(transport).capabilities(ClientCapabilities.builder().sampling().build()).build())
.isInstanceOf(McpError.class)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Sampling handler must not be null when client capabilities include sampling");
}
@ -521,7 +520,7 @@ class McpAsyncClientResponseHandlerTests {
// Create client with elicitation capability but null handler
assertThatThrownBy(() -> McpClient.async(transport)
.capabilities(ClientCapabilities.builder().elicitation().build())
.build()).isInstanceOf(McpError.class)
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Elicitation handler must not be null when client capabilities include elicitation");
}

View file

@ -113,7 +113,7 @@ class McpClientProtocolVersionTests {
new McpSchema.InitializeResult(unsupportedVersion, null,
new McpSchema.Implementation("test-server", "1.0.0"), null),
null));
}).expectError(McpError.class).verify();
}).expectError(RuntimeException.class).verify();
}
finally {
StepVerifier.create(client.closeGracefully()).verifyComplete();

Some files were not shown because too many files have changed in this diff Show more