Skip to content

Commit 4f3f7d9

Browse files
committed
Expose request URI in McpHttpClientAuthorizationErrorHandler
Breaking change Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent accba74 commit 4f3f7d9

8 files changed

Lines changed: 282 additions & 36 deletions

File tree

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
2525
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
2626
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
27+
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientTransportAuthorizationErrorHandler;
2728
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
2829
import io.modelcontextprotocol.common.McpTransportContext;
2930
import io.modelcontextprotocol.json.McpJsonDefaults;
@@ -120,7 +121,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
120121

121122
private final boolean openConnectionOnStartup;
122123

123-
private final McpHttpClientAuthorizationErrorHandler authorizationErrorHandler;
124+
private final McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler;
124125

125126
private final boolean resumableStreams;
126127

@@ -139,7 +140,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
139140
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
140141
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
141142
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
142-
McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions) {
143+
McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler,
144+
List<String> supportedProtocolVersions) {
143145
this.jsonMapper = jsonMapper;
144146
this.httpClient = httpClient;
145147
this.requestBuilder = requestBuilder;
@@ -295,9 +297,12 @@ private Mono<Disposable> reconnect(McpTransportStream<Disposable> stream) {
295297
int statusCode = responseEvent.responseInfo().statusCode();
296298
if (statusCode == 401 || statusCode == 403) {
297299
logger.debug("Authorization error in reconnect with code {}", statusCode);
300+
var request = requestBuilder.build();
301+
var requestSnapshot = new HttpRequestSnapshot(request.uri(), request.method(),
302+
request.headers());
298303
return Mono.<McpSchema.JSONRPCMessage>error(
299304
new McpHttpClientTransportAuthorizationException(
300-
"Authorization error connecting to SSE stream",
305+
"Authorization error connecting to SSE stream", requestSnapshot,
301306
responseEvent.responseInfo()));
302307
}
303308
else if (statusCode == METHOD_NOT_ALLOWED) {
@@ -417,7 +422,8 @@ private Retry authorizationErrorRetrySpec() {
417422
return Mono.deferContextual(ctx -> {
418423
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
419424
return Mono
420-
.from(this.authorizationErrorHandler.handle(authException.getResponseInfo(), transportContext))
425+
.from(this.authorizationErrorHandler.handle(authException.getRequestSnapshot(),
426+
authException.getResponseInfo(), transportContext))
421427
.switchIfEmpty(Mono.just(false))
422428
.flatMap(shouldRetry -> shouldRetry ? Mono.just(retrySignal.totalRetries())
423429
: Mono.error(retrySignal.failure()));
@@ -489,7 +495,6 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
489495
return Mono
490496
.from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext));
491497
}).flatMapMany(requestBuilder -> Flux.<ResponseEvent>create(responseEventSink -> {
492-
493498
// Create the async request with proper body subscriber selection
494499
Mono.fromFuture(this.httpClient
495500
.sendAsync(requestBuilder.build(), this.toSendMessageBodySubscriber(responseEventSink))
@@ -502,12 +507,14 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
502507
}
503508
})).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe();
504509

505-
})).flatMap(responseEvent -> {
510+
}).flatMap(responseEvent -> {
506511
int statusCode = responseEvent.responseInfo().statusCode();
507512
if (statusCode == 401 || statusCode == 403) {
513+
var request = requestBuilder.build();
514+
var requestSnapshot = new HttpRequestSnapshot(request.uri(), request.method(), request.headers());
508515
logger.debug("Authorization error in sendMessage with code {}", statusCode);
509516
return Mono.<McpSchema.JSONRPCMessage>error(new McpHttpClientTransportAuthorizationException(
510-
"Authorization error when sending message", responseEvent.responseInfo()));
517+
"Authorization error when sending message", requestSnapshot, responseEvent.responseInfo()));
511518
}
512519

513520
if (transportSession.markInitialized(
@@ -651,13 +658,12 @@ else if (statusCode == BAD_REQUEST) {
651658
if (ref != null) {
652659
transportSession.removeConnection(ref);
653660
}
654-
})
655-
.contextWrite(deliveredSink.contextView())
656-
.subscribe();
661+
})).contextWrite(deliveredSink.contextView()).subscribe();
657662

658663
disposableRef.set(connection);
659664
transportSession.addConnection(connection);
660665
});
666+
661667
}
662668

663669
private static String sessionIdOrPlaceholder(McpTransportSession<?> transportSession) {
@@ -695,7 +701,7 @@ public static class Builder {
695701
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
696702
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
697703

698-
private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
704+
private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP;
699705

700706
/**
701707
* Creates a new builder with the specified base URI.
@@ -828,8 +834,34 @@ public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer as
828834
* when sending a message.
829835
* @param authorizationErrorHandler the handler
830836
* @return this builder
837+
* @deprecated in favor of
838+
* {@link #authorizationErrorHandler(McpHttpClientTransportAuthorizationErrorHandler)}
831839
*/
840+
@Deprecated(forRemoval = true, since = "2.0.0")
832841
public Builder authorizationErrorHandler(McpHttpClientAuthorizationErrorHandler authorizationErrorHandler) {
842+
this.authorizationErrorHandler = new McpHttpClientTransportAuthorizationErrorHandler() {
843+
@Override
844+
public Publisher<Boolean> handle(HttpRequestSnapshot requestSnapshot,
845+
HttpResponse.ResponseInfo responseInfo, McpTransportContext context) {
846+
return authorizationErrorHandler.handle(responseInfo, context);
847+
}
848+
849+
@Override
850+
public int maxRetries() {
851+
return authorizationErrorHandler.maxRetries();
852+
}
853+
};
854+
return this;
855+
}
856+
857+
/**
858+
* Sets the handler to be used when the server responds with HTTP 401 or HTTP 403
859+
* when sending a message.
860+
* @param authorizationErrorHandler the handler
861+
* @return this builder
862+
*/
863+
public Builder authorizationErrorHandler(
864+
McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler) {
833865
this.authorizationErrorHandler = authorizationErrorHandler;
834866
return this;
835867
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client.transport;
6+
7+
import java.net.URI;
8+
import java.net.http.HttpHeaders;
9+
import java.net.http.HttpRequest;
10+
import java.net.http.HttpRequest.BodyPublisher;
11+
12+
/**
13+
* Captures information about an HTTP request. We use this instead of passing the plain
14+
* {@link HttpRequest} object because we want to avoid retaining a reference to the
15+
* request's {@link BodyPublisher}.
16+
*
17+
* @param requestUri the HTTP request URI
18+
* @param method the HTTP method
19+
* @param headers the HTTP request headers
20+
* @author Daniel Garnier-Moiroux
21+
*/
22+
public record HttpRequestSnapshot(URI requestUri, String method, HttpHeaders headers) {
23+
}

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,21 @@ public class McpHttpClientTransportAuthorizationException extends McpTransportEx
1919

2020
private final HttpResponse.ResponseInfo responseInfo;
2121

22-
public McpHttpClientTransportAuthorizationException(String message, HttpResponse.ResponseInfo responseInfo) {
22+
private final HttpRequestSnapshot requestSnapshot;
23+
24+
public McpHttpClientTransportAuthorizationException(String message, HttpRequestSnapshot requestSnapshot,
25+
HttpResponse.ResponseInfo responseInfo) {
2326
super(message);
2427
this.responseInfo = responseInfo;
28+
this.requestSnapshot = requestSnapshot;
2529
}
2630

2731
public HttpResponse.ResponseInfo getResponseInfo() {
2832
return responseInfo;
2933
}
3034

35+
public HttpRequestSnapshot getRequestSnapshot() {
36+
return requestSnapshot;
37+
}
38+
3139
}

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import java.net.http.HttpResponse;
88

9+
import io.modelcontextprotocol.client.transport.HttpRequestSnapshot;
910
import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException;
1011
import io.modelcontextprotocol.common.McpTransportContext;
1112
import org.reactivestreams.Publisher;
@@ -20,7 +21,9 @@
2021
* "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP
2122
* Specification: Authorization</a>
2223
* @author Daniel Garnier-Moiroux
24+
* @deprecated in favor of {@link McpHttpClientTransportAuthorizationErrorHandler}
2325
*/
26+
@Deprecated(forRemoval = true, since = "2.0.0")
2427
public interface McpHttpClientAuthorizationErrorHandler {
2528

2629
/**
@@ -38,7 +41,10 @@ public interface McpHttpClientAuthorizationErrorHandler {
3841
* @param context the MCP client transport context
3942
* @return {@link Publisher} emitting true if the original request should be replayed,
4043
* false otherwise.
44+
* @deprecated in favor of
45+
* {@link McpHttpClientTransportAuthorizationErrorHandler#handle(HttpRequestSnapshot, HttpResponse.ResponseInfo, McpTransportContext)}
4146
*/
47+
@Deprecated(forRemoval = true, since = "2.0.0")
4248
Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
4349

4450
/**
@@ -87,7 +93,10 @@ interface Sync {
8793
* @param responseInfo the HTTP response information
8894
* @param context the MCP client transport context
8995
* @return true if the original request should be replayed, false otherwise.
96+
* @deprecated in favor of
97+
* {@link McpHttpClientTransportAuthorizationErrorHandler.Sync#handle(HttpRequestSnapshot, HttpResponse.ResponseInfo, McpTransportContext)}
9098
*/
99+
@Deprecated(forRemoval = true, since = "2.0.0")
91100
boolean handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
92101

93102
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client.transport.customizer;
6+
7+
import java.net.http.HttpResponse;
8+
9+
import io.modelcontextprotocol.client.transport.HttpRequestSnapshot;
10+
import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException;
11+
import io.modelcontextprotocol.common.McpTransportContext;
12+
import org.reactivestreams.Publisher;
13+
import reactor.core.publisher.Mono;
14+
import reactor.core.scheduler.Schedulers;
15+
16+
/**
17+
* Handle security-related errors in HTTP-client based transports. This class handles MCP
18+
* server responses with status code 401 and 403.
19+
*
20+
* @see <a href=
21+
* "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP
22+
* Specification: Authorization</a>
23+
* @author Daniel Garnier-Moiroux
24+
*/
25+
public interface McpHttpClientTransportAuthorizationErrorHandler {
26+
27+
/**
28+
* Handle authorization error (HTTP 401 or 403), and signal whether the HTTP request
29+
* should be retried or not. If the publisher returns true, the original transport
30+
* method (connect, sendMessage) will be replayed with the original arguments.
31+
* Otherwise, the transport will throw an
32+
* {@link McpHttpClientTransportAuthorizationException}, indicating the error status.
33+
* <p>
34+
* If the returned {@link Publisher} errors, the error will be propagated to the
35+
* calling method, to be handled by the caller.
36+
* <p>
37+
* The number of retries is bounded by {@link #maxRetries()}.
38+
* @param requestSnapshot the HTTP request snapshot that failed authorization
39+
* @param responseInfo the HTTP response information
40+
* @param context the MCP client transport context
41+
* @return {@link Publisher} emitting true if the original request should be replayed,
42+
* false otherwise.
43+
*/
44+
Publisher<Boolean> handle(HttpRequestSnapshot requestSnapshot, HttpResponse.ResponseInfo responseInfo,
45+
McpTransportContext context);
46+
47+
/**
48+
* Maximum number of authorization error retries the transport will attempt. When the
49+
* handler signals a retry via {@link #handle}, the transport will replay the original
50+
* request at most this many times. If the authorization error persists after
51+
* exhausting all retries, the transport will propagate the
52+
* {@link McpHttpClientTransportAuthorizationException}.
53+
* <p>
54+
* Defaults to {@code 1}.
55+
* @return the maximum number of retries
56+
*/
57+
default int maxRetries() {
58+
return 1;
59+
}
60+
61+
/**
62+
* A no-op handler, used in the default use-case.
63+
*/
64+
McpHttpClientTransportAuthorizationErrorHandler NOOP = new Noop();
65+
66+
/**
67+
* Create a {@link McpHttpClientTransportAuthorizationErrorHandler} from a synchronous
68+
* handler. Will be subscribed on {@link Schedulers#boundedElastic()}. The handler may
69+
* be blocking.
70+
* @param handler the synchronous handler
71+
* @return an async handler
72+
*/
73+
static McpHttpClientTransportAuthorizationErrorHandler fromSync(Sync handler) {
74+
return (snapshot, info, context) -> Mono.fromCallable(() -> handler.handle(snapshot, info, context))
75+
.subscribeOn(Schedulers.boundedElastic());
76+
}
77+
78+
/**
79+
* Synchronous authorization error handler.
80+
*/
81+
interface Sync {
82+
83+
/**
84+
* Handle authorization error (HTTP 401 or 403), and signal whether the HTTP
85+
* request should be retried or not. If the return value is true, the original
86+
* transport method (connect, sendMessage) will be replayed with the original
87+
* arguments. Otherwise, the transport will throw an
88+
* {@link McpHttpClientTransportAuthorizationException}, indicating the error
89+
* status.
90+
* @param requestSnapshot the HTTP request snapshot that failed authorization
91+
* @param responseInfo the HTTP response information
92+
* @param context the MCP client transport context
93+
* @return true if the original request should be replayed, false otherwise.
94+
*/
95+
boolean handle(HttpRequestSnapshot requestSnapshot, HttpResponse.ResponseInfo responseInfo,
96+
McpTransportContext context);
97+
98+
}
99+
100+
class Noop implements McpHttpClientTransportAuthorizationErrorHandler {
101+
102+
@Override
103+
public Publisher<Boolean> handle(HttpRequestSnapshot requestSnapshot, HttpResponse.ResponseInfo responseInfo,
104+
McpTransportContext context) {
105+
return Mono.just(false);
106+
}
107+
108+
}
109+
110+
}

mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,31 @@
1313

1414
/**
1515
* @author Daniel Garnier-Moiroux
16+
* @deprecated use {@link McpHttpClientTransportAuthorizationErrorHandlerTest}
1617
*/
18+
@Deprecated
1719
class McpHttpClientAuthorizationErrorHandlerTest {
1820

1921
private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class);
2022

2123
private final McpTransportContext context = McpTransportContext.EMPTY;
2224

2325
@Test
24-
void whenTrueThenRetry() {
26+
void returnsTrue() {
2527
McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
2628
.fromSync((info, ctx) -> true);
2729
StepVerifier.create(handler.handle(responseInfo, context)).expectNext(true).verifyComplete();
2830
}
2931

3032
@Test
31-
void whenFalseThenError() {
33+
void returnsFalse() {
3234
McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
3335
.fromSync((info, ctx) -> false);
3436
StepVerifier.create(handler.handle(responseInfo, context)).expectNext(false).verifyComplete();
3537
}
3638

3739
@Test
38-
void whenExceptionThenPropagate() {
40+
void propragateExceptions() {
3941
McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
4042
.fromSync((info, ctx) -> {
4143
throw new IllegalStateException("sync handler error");

0 commit comments

Comments
 (0)