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:
parent
6e4ce1c1a3
commit
abcd19c0c2
4 changed files with 771 additions and 131 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue