HttpClientStreamHttpTransport: add authorization error handler (#861)

HttpClientStreamHttpTransport: add authorization error handler

- Closes #240

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-03-13 10:12:57 +01:00 • committed by GitHub
commit abcd19c0c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,26 +1,24 @@
/*
* Copyright 2025-2025 the original author or authors.
* Copyright 2025-2026 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.verify;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
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.Test;
import org.junit.jupiter.api.Timeout;
import java.util.function.Predicate;
import com.sun.net.httpserver.HttpServer;
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
import io.modelcontextprotocol.common.McpTransportContext;
import org.reactivestreams.Publisher;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
@ -28,14 +26,30 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import io.modelcontextprotocol.spec.ProtocolVersions;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for error handling changes in HttpClientStreamableHttpTransport. Specifically
* tests the distinction between session-related errors and general transport errors for
* 404 and 400 status codes.
*
* @author Christian Tzolov
* @author Daniel Garnier-Moiroux
*/
@Timeout(15)
public class HttpClientStreamableHttpTransportErrorHandlingTest {
@ -46,11 +60,17 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest {
private HttpServer server;
private AtomicReference<Integer> serverResponseStatus = new AtomicReference<>(200);
private final AtomicInteger serverResponseStatus = new AtomicInteger(200);
private AtomicReference<String> currentServerSessionId = new AtomicReference<>(null);
private final AtomicInteger serverSseResponseStatus = new AtomicInteger(200);
private AtomicReference<String> lastReceivedSessionId = new AtomicReference<>(null);
private final AtomicReference<String> currentServerSessionId = new AtomicReference<>(null);
private final AtomicReference<String> lastReceivedSessionId = new AtomicReference<>(null);
private final AtomicInteger processedMessagesCount = new AtomicInteger(0);
private final AtomicInteger processedSseConnectCount = new AtomicInteger(0);
private McpClientTransport transport;
@ -88,6 +108,20 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest {
else {
httpExchange.sendResponseHeaders(status, 0);
}
processedMessagesCount.incrementAndGet();
}
else if ("GET".equals(httpExchange.getRequestMethod())) {
int status = serverSseResponseStatus.get();
if (status == 200) {
httpExchange.getResponseHeaders().set("Content-Type", "text/event-stream");
httpExchange.sendResponseHeaders(200, 0);
String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n";
httpExchange.getResponseBody().write(sseData.getBytes());
}
else {
httpExchange.sendResponseHeaders(status, 0);
}
processedSseConnectCount.incrementAndGet();
}
httpExchange.close();
});
@ -103,6 +137,7 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest {
if (server != null) {
server.stop(0);
}
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
/**
@ -334,6 +369,386 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest {
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
@Nested
class AuthorizationError {
@Nested
class SendMessage {
@ParameterizedTest
@ValueSource(ints = { 401, 403 })
void invokeHandler(int httpStatus) {
serverResponseStatus.set(httpStatus);
AtomicReference<HttpResponse.ResponseInfo> capturedResponseInfo = new AtomicReference<>();
AtomicReference<McpTransportContext> capturedContext = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler((responseInfo, context) -> {
capturedResponseInfo.set(responseInfo);
capturedContext.set(context);
return Mono.just(false);
})
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(authorizationError(httpStatus))
.verify();
assertThat(processedMessagesCount.get()).isEqualTo(1);
assertThat(capturedResponseInfo.get()).isNotNull();
assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus);
assertThat(capturedContext.get()).isNotNull();
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void defaultHandler() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST).build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(authorizationError(401))
.verify();
assertThat(processedMessagesCount.get()).isEqualTo(1);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void retry() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler((responseInfo, context) -> {
serverResponseStatus.set(200);
return Mono.just(true);
})
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())).verifyComplete();
// initial request + retry
assertThat(processedMessagesCount.get()).isEqualTo(2);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void retryAtMostOnce() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler((responseInfo, context) -> Mono.just(true))
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(authorizationError(401))
.verify();
// initial request + 1 retry (maxRetries default is 1)
assertThat(processedMessagesCount.get()).isEqualTo(2);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void customMaxRetries() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() {
@Override
public Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo,
McpTransportContext context) {
return Mono.just(true);
}
@Override
public int maxRetries() {
return 3;
}
})
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(authorizationError(401))
.verify();
// initial request + 3 retries
assertThat(processedMessagesCount.get()).isEqualTo(4);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void noRetry() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler((responseInfo, context) -> Mono.just(false))
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(authorizationError(401))
.verify();
assertThat(processedMessagesCount.get()).isEqualTo(1);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void propagateHandlerError() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler(
(responseInfo, context) -> Mono.error(new IllegalStateException("handler error")))
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(throwable -> throwable instanceof IllegalStateException
&& throwable.getMessage().equals("handler error"))
.verify();
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void emptyHandler() {
serverResponseStatus.set(401);
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler((responseInfo, context) -> Mono.empty())
.build();
StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
.expectErrorMatches(authorizationError(401))
.verify();
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
}
@Nested
class Connect {
@ParameterizedTest
@ValueSource(ints = { 401, 403 })
void invokeHandler(int httpStatus) {
serverSseResponseStatus.set(httpStatus);
@SuppressWarnings("unchecked")
AtomicReference<Throwable> capturedException = new AtomicReference<>();
AtomicReference<HttpResponse.ResponseInfo> capturedResponseInfo = new AtomicReference<>();
AtomicReference<McpTransportContext> capturedContext = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.authorizationErrorHandler((responseInfo, context) -> {
capturedResponseInfo.set(responseInfo);
capturedContext.set(context);
return Mono.just(false);
})
.openConnectionOnStartup(true)
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
assertThat(messages).isEmpty();
assertThat(capturedResponseInfo.get()).isNotNull();
assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus);
assertThat(capturedContext.get()).isNotNull();
assertThat(capturedException.get()).hasMessage("Authorization error connecting to SSE stream")
.asInstanceOf(type(McpHttpClientTransportAuthorizationException.class))
.extracting(McpHttpClientTransportAuthorizationException::getResponseInfo)
.extracting(HttpResponse.ResponseInfo::statusCode)
.isEqualTo(httpStatus);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void defaultHandler() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.build();
authTransport.setExceptionHandler(capturedException::set);
StepVerifier.create(authTransport.connect(msg -> msg)).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void retry() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.authorizationErrorHandler((responseInfo, context) -> {
serverSseResponseStatus.set(200);
return Mono.just(true);
})
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
var messageHandlerClosed = new AtomicBoolean(false);
StepVerifier
.create(authTransport
.connect(msg -> msg.doOnNext(messages::add).doFinally(s -> messageHandlerClosed.set(true))))
.verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(messageHandlerClosed).isTrue());
assertThat(processedSseConnectCount.get()).isEqualTo(2);
assertThat(messages).hasSize(1);
assertThat(capturedException.get()).isNull();
assertThat(messageHandlerClosed.get()).isTrue();
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void retryAtMostOnce() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.authorizationErrorHandler((responseInfo, context) -> {
return Mono.just(true);
})
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(capturedException.get()).isNotNull());
// initial request + 1 retry (maxRetries default is 1)
assertThat(processedSseConnectCount.get()).isEqualTo(2);
assertThat(messages).isEmpty();
assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void customMaxRetries() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() {
@Override
public Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo,
McpTransportContext context) {
return Mono.just(true);
}
@Override
public int maxRetries() {
return 3;
}
})
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(capturedException.get()).isNotNull());
// initial request + 3 retries
assertThat(processedSseConnectCount.get()).isEqualTo(4);
assertThat(messages).isEmpty();
assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void noRetry() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.authorizationErrorHandler((responseInfo, context) -> {
// if there was a retry, the request would succeed.
serverSseResponseStatus.set(200);
return Mono.just(false);
})
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
assertThat(messages).isEmpty();
assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void emptyHandler() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.authorizationErrorHandler((responseInfo, context) -> Mono.empty())
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
assertThat(messages).isEmpty();
assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
@Test
void propagateHandlerError() {
serverSseResponseStatus.set(401);
AtomicReference<Throwable> capturedException = new AtomicReference<>();
var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
.openConnectionOnStartup(true)
.authorizationErrorHandler(
(responseInfo, context) -> Mono.error(new IllegalStateException("handler error")))
.build();
authTransport.setExceptionHandler(capturedException::set);
var messages = new ArrayList<McpSchema.JSONRPCMessage>();
StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
Awaitility.await()
.atMost(Duration.ofSeconds(1))
.untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
assertThat(messages).isEmpty();
assertThat(capturedException.get()).isInstanceOf(IllegalStateException.class)
.hasMessage("handler error");
StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
}
}
private static Predicate<Throwable> authorizationError(int httpStatus) {
return throwable -> throwable instanceof McpHttpClientTransportAuthorizationException
&& throwable.getMessage().contains("Authorization error")
&& ((McpHttpClientTransportAuthorizationException) throwable).getResponseInfo()
.statusCode() == httpStatus;
}
}
private McpSchema.JSONRPCRequest createTestRequestMessage() {
var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
McpSchema.ClientCapabilities.builder().roots(true).build(),