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
4 changed files with 771 additions and 131 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright 2024-2025 the original author or authors.
* Copyright 2024-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
@ -23,6 +23,7 @@ import java.util.function.Function;
import io.modelcontextprotocol.client.McpAsyncClient;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
@ -50,6 +51,7 @@ import reactor.core.publisher.FluxSink;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import reactor.util.function.Tuples;
import reactor.util.retry.Retry;
/**
* An implementation of the Streamable HTTP protocol as defined by the
@ -72,6 +74,7 @@ import reactor.util.function.Tuples;
* </p>
*
* @author Christian Tzolov
* @author Daniel Garnier-Moiroux
* @see <a href=
* "https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable
* HTTP transport specification</a>
@ -115,6 +118,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private final boolean openConnectionOnStartup;
private final McpHttpClientAuthorizationErrorHandler authorizationErrorHandler;
private final boolean resumableStreams;
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
@ -132,7 +137,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
List<String> supportedProtocolVersions) {
McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions) {
this.jsonMapper = jsonMapper;
this.httpClient = httpClient;
this.requestBuilder = requestBuilder;
@ -140,6 +145,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
this.endpoint = endpoint;
this.resumableStreams = resumableStreams;
this.openConnectionOnStartup = openConnectionOnStartup;
this.authorizationErrorHandler = authorizationErrorHandler;
this.activeSession.set(createTransportSession());
this.httpRequestCustomizer = httpRequestCustomizer;
this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions);
@ -239,7 +245,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
}
private Mono<Disposable> reconnect(McpTransportStream<Disposable> stream) {
return Mono.deferContextual(ctx -> {
if (stream != null) {
@ -275,121 +280,120 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
var transportContext = connectionCtx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
})
.flatMapMany(
requestBuilder -> Flux.<ResponseEvent>create(
sseSink -> this.httpClient
.sendAsync(requestBuilder.build(),
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo,
sseSink))
.whenComplete((response, throwable) -> {
if (throwable != null) {
sseSink.error(throwable);
}
else {
logger.debug("SSE connection established successfully");
}
}))
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
.flatMap(responseEvent -> {
int statusCode = responseEvent.responseInfo().statusCode();
.flatMapMany(requestBuilder -> Flux.<ResponseEvent>create(sseSink -> this.httpClient
.sendAsync(requestBuilder.build(), this.toSendMessageBodySubscriber(sseSink))
.whenComplete((response, throwable) -> {
if (throwable != null) {
sseSink.error(throwable);
}
else {
logger.debug("SSE connection established successfully");
}
})).flatMap(responseEvent -> {
int statusCode = responseEvent.responseInfo().statusCode();
if (statusCode == 401 || statusCode == 403) {
logger.debug("Authorization error in reconnect with code {}", statusCode);
return Mono.<McpSchema.JSONRPCMessage>error(
new McpHttpClientTransportAuthorizationException(
"Authorization error connecting to SSE stream",
responseEvent.responseInfo()));
}
if (statusCode >= 200 && statusCode < 300) {
if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
String data = responseEvent.sseEvent().data();
// Per 2025-11-25 spec (SEP-1699), servers may
// send SSE events
// with empty data to prime the client for
// reconnection.
// Skip these events as they contain no JSON-RPC
// message.
if (data == null || data.isBlank()) {
logger.debug("Skipping SSE event with empty data (stream primer)");
return Flux.empty();
}
try {
// We don't support batching ATM and probably
// won't since the next version considers
// removing it.
McpSchema.JSONRPCMessage message = McpSchema
.deserializeJsonRpcMessage(this.jsonMapper, data);
Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> idWithMessages = Tuples
.of(Optional.ofNullable(responseEvent.sseEvent().id()),
List.of(message));
McpTransportStream<Disposable> sessionStream = stream != null ? stream
: new DefaultMcpTransportStream<>(this.resumableStreams,
this::reconnect);
logger.debug("Connected stream {}", sessionStream.streamId());
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
}
catch (IOException ioException) {
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Error parsing JSON-RPC message: " + responseEvent, ioException));
}
}
else {
logger.debug("Received SSE event with type: {}", responseEvent.sseEvent());
return Flux.empty();
}
}
else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
logger
.debug("The server does not support SSE streams, using request-response mode.");
if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) {
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Unrecognized server error when connecting to SSE stream, status code: "
+ statusCode));
}
else if (statusCode >= 200 && statusCode < 300) {
if (MESSAGE_EVENT_TYPE.equals(sseResponseEvent.sseEvent().event())) {
String data = sseResponseEvent.sseEvent().data();
// Per 2025-11-25 spec (SEP-1699), servers may
// send SSE events
// with empty data to prime the client for
// reconnection.
// Skip these events as they contain no JSON-RPC
// message.
if (data == null || data.isBlank()) {
logger.debug("Skipping SSE event with empty data (stream primer)");
return Flux.empty();
}
else if (statusCode == NOT_FOUND) {
try {
// We don't support batching ATM and probably
// won't since the next version considers
// removing it.
McpSchema.JSONRPCMessage message = McpSchema
.deserializeJsonRpcMessage(this.jsonMapper, data);
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) {
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));
Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> idWithMessages = Tuples
.of(Optional.ofNullable(sseResponseEvent.sseEvent().id()), List.of(message));
McpTransportStream<Disposable> sessionStream = stream != null ? stream
: new DefaultMcpTransportStream<>(this.resumableStreams, this::reconnect);
logger.debug("Connected stream {}", sessionStream.streamId());
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
}
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Received unrecognized SSE event type: " + responseEvent.sseEvent().event()));
}).<McpSchema
.JSONRPCMessage>flatMap(
jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
.onErrorMap(CompletionException.class, t -> t.getCause())
.onErrorComplete(t -> {
this.handleException(t);
return true;
})
.doFinally(s -> {
Disposable ref = disposableRef.getAndSet(null);
if (ref != null) {
transportSession.removeConnection(ref);
catch (IOException ioException) {
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
"Error parsing JSON-RPC message: " + responseEvent, ioException));
}
}))
}
else {
logger.debug("Received SSE event with type: {}", sseResponseEvent.sseEvent());
return Flux.empty();
}
}
else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
logger.debug("The server does not support SSE streams, using request-response mode.");
return Flux.empty();
}
else if (statusCode == NOT_FOUND) {
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) {
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 McpTransportException(
"Received unrecognized SSE event type: " + sseResponseEvent.sseEvent().event()));
})
.retryWhen(authorizationErrorRetrySpec())
.flatMap(jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
.onErrorMap(CompletionException.class, t -> t.getCause())
.onErrorComplete(t -> {
this.handleException(t);
return true;
})
.doFinally(s -> {
Disposable ref = disposableRef.getAndSet(null);
if (ref != null) {
transportSession.removeConnection(ref);
}
}))
.contextWrite(ctx)
.subscribe();
@ -400,6 +404,25 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
}
private Retry authorizationErrorRetrySpec() {
return Retry.from(companion -> companion.flatMap(retrySignal -> {
if (!(retrySignal.failure() instanceof McpHttpClientTransportAuthorizationException authException)) {
return Mono.error(retrySignal.failure());
}
if (retrySignal.totalRetriesInARow() >= this.authorizationErrorHandler.maxRetries()) {
return Mono.error(retrySignal.failure());
}
return Mono.deferContextual(ctx -> {
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono
.from(this.authorizationErrorHandler.handle(authException.getResponseInfo(), transportContext))
.switchIfEmpty(Mono.just(false))
.flatMap(shouldRetry -> shouldRetry ? Mono.just(retrySignal.totalRetries())
: Mono.error(retrySignal.failure()));
});
}));
}
private BodyHandler<Void> toSendMessageBodySubscriber(FluxSink<ResponseEvent> sink) {
BodyHandler<Void> responseBodyHandler = responseInfo -> {
@ -478,6 +501,13 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
})).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe();
})).flatMap(responseEvent -> {
int statusCode = responseEvent.responseInfo().statusCode();
if (statusCode == 401 || statusCode == 403) {
logger.debug("Authorization error in sendMessage with code {}", statusCode);
return Mono.<McpSchema.JSONRPCMessage>error(new McpHttpClientTransportAuthorizationException(
"Authorization error when sending message", responseEvent.responseInfo()));
}
if (transportSession.markInitialized(
responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
// Once we have a session, we try to open an async stream for
@ -488,8 +518,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
String sessionRepresentation = sessionIdOrPlaceholder(transportSession);
int statusCode = responseEvent.responseInfo().statusCode();
if (statusCode >= 200 && statusCode < 300) {
String contentType = responseEvent.responseInfo()
@ -605,6 +633,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return Flux.<McpSchema.JSONRPCMessage>error(
new RuntimeException("Failed to send message: " + responseEvent));
})
.retryWhen(authorizationErrorRetrySpec())
.flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage)))
.onErrorMap(CompletionException.class, t -> t.getCause())
.onErrorComplete(t -> {
@ -664,6 +693,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
/**
* Creates a new builder with the specified base URI.
* @param baseUri the base URI of the MCP server
@ -801,6 +832,17 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
return this;
}
/**
* Sets the handler to be used when the server responds with HTTP 401 or HTTP 403
* when sending a message.
* @param authorizationErrorHandler the handler
* @return this builder
*/
public Builder authorizationErrorHandler(McpHttpClientAuthorizationErrorHandler authorizationErrorHandler) {
this.authorizationErrorHandler = authorizationErrorHandler;
return this;
}
/**
* Sets the connection timeout for the HTTP client.
* @param connectTimeout the connection timeout duration
@ -845,7 +887,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
httpRequestCustomizer, supportedProtocolVersions);
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
import java.net.http.HttpResponse;
import io.modelcontextprotocol.spec.McpTransportException;
/**
* Thrown when the MCP server responds with an authorization error (HTTP 401 or HTTP 403).
* Subclass of {@link McpTransportException} for targeted retry handling in
* {@link HttpClientStreamableHttpTransport}.
*
* @author Daniel Garnier-Moiroux
*/
public class McpHttpClientTransportAuthorizationException extends McpTransportException {
private final HttpResponse.ResponseInfo responseInfo;
public McpHttpClientTransportAuthorizationException(String message, HttpResponse.ResponseInfo responseInfo) {
super(message);
this.responseInfo = responseInfo;
}
public HttpResponse.ResponseInfo getResponseInfo() {
return responseInfo;
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport.customizer;
import java.net.http.HttpResponse;
import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException;
import io.modelcontextprotocol.common.McpTransportContext;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
/**
* Handle security-related errors in HTTP-client based transports. This class handles MCP
* server responses with status code 401 and 403.
*
* @see <a href=
* "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP
* Specification: Authorization</a>
* @author Daniel Garnier-Moiroux
*/
public interface McpHttpClientAuthorizationErrorHandler {
/**
* Handle authorization error (HTTP 401 or 403), and signal whether the HTTP request
* should be retried or not. If the publisher returns true, the original transport
* method (connect, sendMessage) will be replayed with the original arguments.
* Otherwise, the transport will throw an
* {@link McpHttpClientTransportAuthorizationException}, indicating the error status.
* <p>
* If the returned {@link Publisher} errors, the error will be propagated to the
* calling method, to be handled by the caller.
* <p>
* The number of retries is bounded by {@link #maxRetries()}.
* @param responseInfo the HTTP response information
* @param context the MCP client transport context
* @return {@link Publisher} emitting true if the original request should be replayed,
* false otherwise.
*/
Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
/**
* Maximum number of authorization error retries the transport will attempt. When the
* handler signals a retry via {@link #handle}, the transport will replay the original
* request at most this many times. If the authorization error persists after
* exhausting all retries, the transport will propagate the
* {@link McpHttpClientTransportAuthorizationException}.
* <p>
* Defaults to {@code 1}.
* @return the maximum number of retries
*/
default int maxRetries() {
return 1;
}
/**
* A no-op handler, used in the default use-case.
*/
McpHttpClientAuthorizationErrorHandler NOOP = new Noop();
/**
* Create a {@link McpHttpClientAuthorizationErrorHandler} from a synchronous handler.
* Will be subscribed on {@link Schedulers#boundedElastic()}. The handler may be
* blocking.
* @param handler the synchronous handler
* @return an async handler
*/
static McpHttpClientAuthorizationErrorHandler fromSync(Sync handler) {
return (info, context) -> Mono.fromCallable(() -> handler.handle(info, context))
.subscribeOn(Schedulers.boundedElastic());
}
/**
* Synchronous authorization error handler.
*/
interface Sync {
/**
* Handle authorization error (HTTP 401 or 403), and signal whether the HTTP
* request should be retried or not. If the return value is true, the original
* transport method (connect, sendMessage) will be replayed with the original
* arguments. Otherwise, the transport will throw an
* {@link McpHttpClientTransportAuthorizationException}, indicating the error
* status.
* @param responseInfo the HTTP response information
* @param context the MCP client transport context
* @return true if the original request should be replayed, false otherwise.
*/
boolean handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
}
class Noop implements McpHttpClientAuthorizationErrorHandler {
@Override
public Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context) {
return Mono.just(false);
}
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport.customizer;
import java.net.http.HttpResponse;
import io.modelcontextprotocol.common.McpTransportContext;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static org.mockito.Mockito.mock;
/**
* @author Daniel Garnier-Moiroux
*/
class McpHttpClientAuthorizationErrorHandlerTest {
private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class);
private final McpTransportContext context = McpTransportContext.EMPTY;
@Test
void whenTrueThenRetry() {
McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
.fromSync((info, ctx) -> true);
StepVerifier.create(handler.handle(responseInfo, context)).expectNext(true).verifyComplete();
}
@Test
void whenFalseThenError() {
McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
.fromSync((info, ctx) -> false);
StepVerifier.create(handler.handle(responseInfo, context)).expectNext(false).verifyComplete();
}
@Test
void whenExceptionThenPropagate() {
McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
.fromSync((info, ctx) -> {
throw new IllegalStateException("sync handler error");
});
StepVerifier.create(handler.handle(responseInfo, context))
.expectErrorMatches(t -> t instanceof IllegalStateException && t.getMessage().equals("sync handler error"))
.verify();
}
}