Skip to content

Commit f8c77f9

Browse files
committed
HttpClientStreamHttpTransport: add authorization error handler
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 6e4ce1c commit f8c77f9

File tree

4 files changed

+307
-15
lines changed

4 files changed

+307
-15
lines changed

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.modelcontextprotocol.client.McpAsyncClient;
2424
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
2525
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
26+
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
2627
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
2728
import io.modelcontextprotocol.common.McpTransportContext;
2829
import io.modelcontextprotocol.json.McpJsonDefaults;
@@ -115,6 +116,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
115116

116117
private final boolean openConnectionOnStartup;
117118

119+
private final McpHttpClientAuthorizationErrorHandler authorizationErrorHandler;
120+
118121
private final boolean resumableStreams;
119122

120123
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
@@ -132,14 +135,15 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
132135
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
133136
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
134137
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
135-
List<String> supportedProtocolVersions) {
138+
McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions) {
136139
this.jsonMapper = jsonMapper;
137140
this.httpClient = httpClient;
138141
this.requestBuilder = requestBuilder;
139142
this.baseUri = URI.create(baseUri);
140143
this.endpoint = endpoint;
141144
this.resumableStreams = resumableStreams;
142145
this.openConnectionOnStartup = openConnectionOnStartup;
146+
this.authorizationErrorHandler = authorizationErrorHandler;
143147
this.activeSession.set(createTransportSession());
144148
this.httpRequestCustomizer = httpRequestCustomizer;
145149
this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions);
@@ -478,6 +482,17 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
478482
})).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe();
479483

480484
})).flatMap(responseEvent -> {
485+
int statusCode = responseEvent.responseInfo().statusCode();
486+
if (statusCode == 401 || statusCode == 403) {
487+
return Mono.deferContextual(ctx -> {
488+
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
489+
return Mono.from(this.authorizationErrorHandler.handle(responseEvent.responseInfo(),
490+
transportContext, Mono.defer(() -> this.sendMessage(sentMessage))));
491+
})
492+
.then(Mono.error(new McpHttpClientTransportException("Authorization error when sending message",
493+
responseEvent.responseInfo())));
494+
}
495+
481496
if (transportSession.markInitialized(
482497
responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
483498
// Once we have a session, we try to open an async stream for
@@ -488,8 +503,6 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
488503

489504
String sessionRepresentation = sessionIdOrPlaceholder(transportSession);
490505

491-
int statusCode = responseEvent.responseInfo().statusCode();
492-
493506
if (statusCode >= 200 && statusCode < 300) {
494507

495508
String contentType = responseEvent.responseInfo()
@@ -664,6 +677,8 @@ public static class Builder {
664677
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
665678
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
666679

680+
private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
681+
667682
/**
668683
* Creates a new builder with the specified base URI.
669684
* @param baseUri the base URI of the MCP server
@@ -801,6 +816,16 @@ public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer as
801816
return this;
802817
}
803818

819+
/**
820+
* Sets the handler to be used when the server responds with HTTP 401 or HTTP 403 when sending a message.
821+
* @param authorizationErrorHandler the handler
822+
* @return this builder
823+
*/
824+
public Builder authorizationErrorHandler(McpHttpClientAuthorizationErrorHandler authorizationErrorHandler) {
825+
this.authorizationErrorHandler = authorizationErrorHandler;
826+
return this;
827+
}
828+
804829
/**
805830
* Sets the connection timeout for the HTTP client.
806831
* @param connectTimeout the connection timeout duration
@@ -845,7 +870,7 @@ public HttpClientStreamableHttpTransport build() {
845870
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
846871
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
847872
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
848-
httpRequestCustomizer, supportedProtocolVersions);
873+
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
849874
}
850875

851876
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client.transport;
6+
7+
import java.net.http.HttpResponse;
8+
9+
import io.modelcontextprotocol.spec.McpTransportException;
10+
11+
/**
12+
* Authorization-related exception for {@link java.net.http.HttpClient}-based client
13+
* transport. Thrown when the server responds with HTTP 401 or HTTP 403. Wraps the
14+
* response info for further inspection of the headers and the status code.
15+
*
16+
* @see <a href=
17+
* "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP
18+
* Specification: Authorization</a>
19+
* @author Daniel Garnier-Moiroux
20+
*/
21+
public class McpHttpClientTransportException extends McpTransportException {
22+
23+
private final HttpResponse.ResponseInfo responseInfo;
24+
25+
public McpHttpClientTransportException(String message, HttpResponse.ResponseInfo responseInfo) {
26+
super(message);
27+
this.responseInfo = responseInfo;
28+
}
29+
30+
public HttpResponse.ResponseInfo getResponseInfo() {
31+
return responseInfo;
32+
}
33+
34+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.common.McpTransportContext;
10+
import org.reactivestreams.Publisher;
11+
import reactor.core.publisher.Mono;
12+
import reactor.core.scheduler.Schedulers;
13+
14+
/**
15+
* Handle security-related errors in HTTP-client based transports. This class handles MCP
16+
* server responses with status code 401 and 403.
17+
*
18+
* @see <a href=
19+
* "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP
20+
* Specification: Authorization</a>
21+
* @author Daniel Garnier-Moiroux
22+
*/
23+
public interface McpHttpClientAuthorizationErrorHandler {
24+
25+
/**
26+
* Handle HTTP error, and signal whether the HTTP request should be retried or not.
27+
* @param responseInfo the HTTP response information
28+
* @param context the MCP client transport context
29+
* @return {@link Publisher} emitting true if the original request should be replayed,
30+
* false otherwise.
31+
*/
32+
Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
33+
34+
/**
35+
* A no-op handler, used in the default use-case.
36+
*/
37+
McpHttpClientAuthorizationErrorHandler NOOP = new Noop();
38+
39+
/**
40+
* Handle HTTP error, and optionally retry the HTTP request. Defaults to
41+
* {@link #handle(HttpResponse.ResponseInfo, McpTransportContext)}, and uses the
42+
* boolean from the return value to decide whether it should retry the request.
43+
* @param responseInfo the HTTP response information
44+
* @param context the MCP client transport context
45+
* @param retryHandler the handler to use to retry the HTTP request.
46+
* @return a {@link Publisher} to signal either an error or a retry
47+
*/
48+
default Publisher<Void> handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context,
49+
Publisher<Void> retryHandler) {
50+
return Mono.from(this.handle(responseInfo, context))
51+
.flatMap(shouldRetry -> shouldRetry != null && shouldRetry ? Mono.from(retryHandler) : Mono.empty());
52+
}
53+
54+
/**
55+
* Create a {@link McpHttpClientAuthorizationErrorHandler} from a synchronous handler.
56+
* Will be subscribed on {@link Schedulers#boundedElastic()}. The handler may be
57+
* blocking.
58+
* @param handler the synchronous handler
59+
* @return an async handler
60+
*/
61+
static McpHttpClientAuthorizationErrorHandler fromSync(Sync handler) {
62+
return (info, context) -> {
63+
try {
64+
var shouldRetry = handler.handle(info, context);
65+
return Mono.just(shouldRetry).subscribeOn(Schedulers.boundedElastic());
66+
}
67+
catch (Exception e) {
68+
return Mono.error(e);
69+
}
70+
};
71+
}
72+
73+
/**
74+
* Synchronous authorization error handler.
75+
*/
76+
interface Sync {
77+
78+
/**
79+
* Handle HTTP error, and signal whether the HTTP request should be retried or
80+
* not.
81+
* @param responseInfo the HTTP response information
82+
* @param context the MCP client transport context
83+
* @return true if the original request should be replayed, false otherwise.
84+
*/
85+
boolean handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
86+
87+
}
88+
89+
class Noop implements McpHttpClientAuthorizationErrorHandler {
90+
91+
@Override
92+
public Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context) {
93+
return Mono.just(false);
94+
}
95+
96+
}
97+
98+
}

0 commit comments

Comments
 (0)