Skip to content

Commit 2bb95bc

Browse files
committed
Bound HTTP client reads
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 9e4d5fa commit 2bb95bc

9 files changed

Lines changed: 1199 additions & 29 deletions

File tree

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

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
8282
/** Default SSE endpoint path */
8383
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
8484

85+
/**
86+
* Default maximum number of bytes read for a single inbound message.
87+
*/
88+
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
89+
8590
/** Base URI for the MCP server */
8691
private final URI baseUri;
8792

@@ -122,6 +127,12 @@ public class HttpClientSseClientTransport implements McpClientTransport {
122127
*/
123128
private final SseMessageEndpointValidator messageEndpointValidator;
124129

130+
/**
131+
* Maximum number of bytes read for a single inbound message, whether it arrives on
132+
* the SSE stream or as the response to a posted message.
133+
*/
134+
private final int maxResponseSize;
135+
125136
/**
126137
* Creates a new transport instance with custom HTTP client builder, object mapper,
127138
* and headers.
@@ -133,25 +144,29 @@ public class HttpClientSseClientTransport implements McpClientTransport {
133144
* @param httpRequestCustomizer customizer for the requestBuilder before executing
134145
* requests
135146
* @param messageEndpointValidator validator for the message endpoint
147+
* @param maxResponseSize the maximum number of bytes read for a single inbound
148+
* message
136149
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
137150
*/
138151
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
139152
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
140-
SseMessageEndpointValidator messageEndpointValidator) {
153+
SseMessageEndpointValidator messageEndpointValidator, int maxResponseSize) {
141154
Assert.notNull(jsonMapper, "jsonMapper must not be null");
142155
Assert.hasText(baseUri, "baseUri must not be empty");
143156
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
144157
Assert.notNull(httpClient, "httpClient must not be null");
145158
Assert.notNull(requestBuilder, "requestBuilder must not be null");
146159
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
147160
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
161+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
148162
this.baseUri = URI.create(baseUri);
149163
this.sseEndpoint = sseEndpoint;
150164
this.jsonMapper = jsonMapper;
151165
this.httpClient = httpClient;
152166
this.requestBuilder = requestBuilder;
153167
this.httpRequestCustomizer = httpRequestCustomizer;
154168
this.messageEndpointValidator = messageEndpointValidator;
169+
this.maxResponseSize = maxResponseSize;
155170
}
156171

157172
@Override
@@ -189,6 +204,8 @@ public static class Builder {
189204

190205
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
191206

207+
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
208+
192209
/**
193210
* Creates a new builder instance.
194211
*/
@@ -338,6 +355,26 @@ public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpo
338355
return this;
339356
}
340357

358+
/**
359+
* Sets the maximum number of bytes read for a single inbound message, whether it
360+
* arrives on the SSE stream or as the response to a posted message. A peer that
361+
* sends a larger message (or never terminates one) has its stream aborted instead
362+
* of forcing the transport to buffer it in memory. Defaults to 16MiB.
363+
*
364+
* <p>
365+
* The bound applies per message, not to the stream as a whole: a long-lived SSE
366+
* stream may deliver any number of messages, each up to this size. SSE field
367+
* framing is allowed a small amount of headroom on top of this size, so a message
368+
* of exactly this many bytes is still accepted.
369+
* @param maxResponseSize the maximum inbound message size, in bytes
370+
* @return this builder
371+
*/
372+
public Builder maxResponseSize(int maxResponseSize) {
373+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
374+
this.maxResponseSize = maxResponseSize;
375+
return this;
376+
}
377+
341378
/**
342379
* Builds a new {@link HttpClientSseClientTransport} instance.
343380
* @return a new transport instance
@@ -346,7 +383,7 @@ public HttpClientSseClientTransport build() {
346383
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
347384
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
348385
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
349-
messageEndpointValidator);
386+
messageEndpointValidator, maxResponseSize);
350387
}
351388

352389
}
@@ -365,13 +402,15 @@ public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> h
365402
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
366403
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
367404
}).flatMap(requestBuilder -> Mono.create(sink -> {
368-
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
369-
.sendAsync(requestBuilder.build(),
370-
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
371-
.exceptionallyCompose(e -> {
372-
sseSink.error(e);
373-
return CompletableFuture.failedFuture(e);
374-
}))
405+
Disposable connection = Flux.<ResponseEvent>create(
406+
sseSink -> this.httpClient
407+
.sendAsync(requestBuilder.build(),
408+
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink,
409+
this.maxResponseSize))
410+
.exceptionallyCompose(e -> {
411+
sseSink.error(e);
412+
return CompletableFuture.failedFuture(e);
413+
}))
375414
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
376415
.flatMap(responseEvent -> {
377416
if (isClosing) {
@@ -502,7 +541,8 @@ private Mono<HttpResponse<String>> sendHttpPost(final String endpoint, final Str
502541
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
503542
}).flatMap(customizedBuilder -> {
504543
var request = customizedBuilder.build();
505-
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
544+
return Mono.fromFuture(
545+
httpClient.sendAsync(request, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
506546
});
507547
}
508548

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

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import java.net.URI;
99
import java.net.http.HttpClient;
1010
import java.net.http.HttpRequest;
11-
import java.net.http.HttpResponse;
1211
import java.net.http.HttpResponse.BodyHandler;
1312
import java.time.Duration;
1413
import java.util.Collections;
@@ -85,6 +84,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
8584

8685
private static final String DEFAULT_ENDPOINT = "/mcp";
8786

87+
/**
88+
* Default maximum number of bytes read for a single inbound message.
89+
*/
90+
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
91+
8892
/**
8993
* HTTP client for sending messages to the server. Uses HTTP POST over the message
9094
* endpoint
@@ -136,10 +140,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
136140

137141
private final String latestSupportedProtocolVersion;
138142

143+
/**
144+
* Maximum number of bytes read for a single inbound message, whether it arrives on an
145+
* SSE stream or as a JSON response body.
146+
*/
147+
private final int maxResponseSize;
148+
139149
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
140150
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
141151
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
142-
McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions) {
152+
McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List<String> supportedProtocolVersions,
153+
int maxResponseSize) {
154+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
143155
this.jsonMapper = jsonMapper;
144156
this.httpClient = httpClient;
145157
this.requestBuilder = requestBuilder;
@@ -155,6 +167,7 @@ private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient h
155167
.sorted(Comparator.reverseOrder())
156168
.findFirst()
157169
.get();
170+
this.maxResponseSize = maxResponseSize;
158171
}
159172

160173
@Override
@@ -211,7 +224,8 @@ private Publisher<Void> createDelete(String sessionId) {
211224
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
212225
}).flatMap(requestBuilder -> {
213226
var request = requestBuilder.build();
214-
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
227+
return Mono.fromFuture(() -> this.httpClient.sendAsync(request,
228+
ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
215229
}).then();
216230
}
217231

@@ -434,16 +448,16 @@ private BodyHandler<Void> toSendMessageBodySubscriber(FluxSink<ResponseEvent> si
434448
if (contentType.contains(TEXT_EVENT_STREAM)) {
435449
// For SSE streams, use line subscriber that returns Void
436450
logger.debug("Received SSE stream response, using line subscriber");
437-
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
451+
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink, this.maxResponseSize);
438452
}
439453
else if (contentType.contains(APPLICATION_JSON)) {
440454
// For JSON responses and others, use string subscriber
441455
logger.debug("Received response, using string subscriber");
442-
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
456+
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink, this.maxResponseSize);
443457
}
444458

445459
logger.debug("Received Bodyless response, using discarding subscriber");
446-
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
460+
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
447461
};
448462

449463
return responseBodyHandler;
@@ -701,6 +715,8 @@ public static class Builder {
701715

702716
private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
703717

718+
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
719+
704720
/**
705721
* Creates a new builder with the specified base URI.
706722
* @param baseUri the base URI of the MCP server
@@ -891,6 +907,26 @@ public Builder supportedProtocolVersions(List<String> supportedProtocolVersions)
891907
return this;
892908
}
893909

910+
/**
911+
* Sets the maximum number of bytes read for a single inbound message, whether it
912+
* arrives on an SSE stream or as a JSON response body. A peer that sends a larger
913+
* message (or never terminates one) has its stream aborted instead of forcing the
914+
* transport to buffer it in memory. Defaults to 16MiB.
915+
*
916+
* <p>
917+
* The bound applies per message, not to the stream as a whole: a long-lived SSE
918+
* stream may deliver any number of messages, each up to this size. SSE field
919+
* framing is allowed a small amount of headroom on top of this size, so a message
920+
* of exactly this many bytes is still accepted.
921+
* @param maxResponseSize the maximum inbound message size, in bytes
922+
* @return this builder
923+
*/
924+
public Builder maxResponseSize(int maxResponseSize) {
925+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
926+
this.maxResponseSize = maxResponseSize;
927+
return this;
928+
}
929+
894930
/**
895931
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
896932
* the current builder configuration.
@@ -900,7 +936,7 @@ public HttpClientStreamableHttpTransport build() {
900936
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
901937
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
902938
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
903-
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
939+
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize);
904940
}
905941

906942
}

0 commit comments

Comments
 (0)