Skip to content

Commit ef037da

Browse files
committed
Bound HTTP client reads
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 6a41ded commit ef037da

9 files changed

Lines changed: 1198 additions & 28 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
@@ -88,6 +88,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
8888
/** Default SSE endpoint path */
8989
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
9090

91+
/**
92+
* Default maximum number of bytes read for a single inbound message.
93+
*/
94+
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
95+
9196
/** Base URI for the MCP server */
9297
private final URI baseUri;
9398

@@ -128,6 +133,12 @@ public class HttpClientSseClientTransport implements McpClientTransport {
128133
*/
129134
private final SseMessageEndpointValidator messageEndpointValidator;
130135

136+
/**
137+
* Maximum number of bytes read for a single inbound message, whether it arrives on
138+
* the SSE stream or as the response to a posted message.
139+
*/
140+
private final int maxResponseSize;
141+
131142
/**
132143
* Creates a new transport instance with custom HTTP client builder, object mapper,
133144
* and headers.
@@ -139,25 +150,29 @@ public class HttpClientSseClientTransport implements McpClientTransport {
139150
* @param httpRequestCustomizer customizer for the requestBuilder before executing
140151
* requests
141152
* @param messageEndpointValidator validator for the message endpoint
153+
* @param maxResponseSize the maximum number of bytes read for a single inbound
154+
* message
142155
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
143156
*/
144157
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
145158
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
146-
SseMessageEndpointValidator messageEndpointValidator) {
159+
SseMessageEndpointValidator messageEndpointValidator, int maxResponseSize) {
147160
Assert.notNull(jsonMapper, "jsonMapper must not be null");
148161
Assert.hasText(baseUri, "baseUri must not be empty");
149162
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
150163
Assert.notNull(httpClient, "httpClient must not be null");
151164
Assert.notNull(requestBuilder, "requestBuilder must not be null");
152165
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
153166
Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null");
167+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
154168
this.baseUri = URI.create(baseUri);
155169
this.sseEndpoint = sseEndpoint;
156170
this.jsonMapper = jsonMapper;
157171
this.httpClient = httpClient;
158172
this.requestBuilder = requestBuilder;
159173
this.httpRequestCustomizer = httpRequestCustomizer;
160174
this.messageEndpointValidator = messageEndpointValidator;
175+
this.maxResponseSize = maxResponseSize;
161176
}
162177

163178
@Override
@@ -195,6 +210,8 @@ public static class Builder {
195210

196211
private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator();
197212

213+
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
214+
198215
/**
199216
* Creates a new builder instance.
200217
*/
@@ -326,6 +343,26 @@ public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpo
326343
return this;
327344
}
328345

346+
/**
347+
* Sets the maximum number of bytes read for a single inbound message, whether it
348+
* arrives on the SSE stream or as the response to a posted message. A peer that
349+
* sends a larger message (or never terminates one) has its stream aborted instead
350+
* of forcing the transport to buffer it in memory. Defaults to 16MiB.
351+
*
352+
* <p>
353+
* The bound applies per message, not to the stream as a whole: a long-lived SSE
354+
* stream may deliver any number of messages, each up to this size. SSE field
355+
* framing is allowed a small amount of headroom on top of this size, so a message
356+
* of exactly this many bytes is still accepted.
357+
* @param maxResponseSize the maximum inbound message size, in bytes
358+
* @return this builder
359+
*/
360+
public Builder maxResponseSize(int maxResponseSize) {
361+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
362+
this.maxResponseSize = maxResponseSize;
363+
return this;
364+
}
365+
329366
/**
330367
* Builds a new {@link HttpClientSseClientTransport} instance.
331368
* @return a new transport instance
@@ -334,7 +371,7 @@ public HttpClientSseClientTransport build() {
334371
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
335372
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
336373
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer,
337-
messageEndpointValidator);
374+
messageEndpointValidator, maxResponseSize);
338375
}
339376

340377
}
@@ -353,13 +390,15 @@ public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> h
353390
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
354391
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
355392
}).flatMap(requestBuilder -> Mono.create(sink -> {
356-
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
357-
.sendAsync(requestBuilder.build(),
358-
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
359-
.exceptionallyCompose(e -> {
360-
sseSink.error(e);
361-
return CompletableFuture.failedFuture(e);
362-
}))
393+
Disposable connection = Flux.<ResponseEvent>create(
394+
sseSink -> this.httpClient
395+
.sendAsync(requestBuilder.build(),
396+
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink,
397+
this.maxResponseSize))
398+
.exceptionallyCompose(e -> {
399+
sseSink.error(e);
400+
return CompletableFuture.failedFuture(e);
401+
}))
363402
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
364403
.flatMap(responseEvent -> {
365404
if (isClosing) {
@@ -490,7 +529,8 @@ private Mono<HttpResponse<String>> sendHttpPost(final String endpoint, final Str
490529
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
491530
}).flatMap(customizedBuilder -> {
492531
var request = customizedBuilder.build();
493-
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
532+
return Mono.fromFuture(
533+
httpClient.sendAsync(request, ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
494534
});
495535
}
496536

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
8787

8888
private static final String DEFAULT_ENDPOINT = "/mcp";
8989

90+
/**
91+
* Default maximum number of bytes read for a single inbound message.
92+
*/
93+
private static final int DEFAULT_MAX_RESPONSE_SIZE = 16 * 1024 * 1024; // 16MiB
94+
9095
/**
9196
* HTTP client for sending messages to the server. Uses HTTP POST over the message
9297
* endpoint
@@ -161,11 +166,18 @@ static boolean isMessageEvent(String eventName) {
161166

162167
private final String latestSupportedProtocolVersion;
163168

169+
/**
170+
* Maximum number of bytes read for a single inbound message, whether it arrives on an
171+
* SSE stream or as a JSON response body.
172+
*/
173+
private final int maxResponseSize;
174+
164175
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
165176
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
166177
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
167178
McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler,
168-
List<String> supportedProtocolVersions) {
179+
List<String> supportedProtocolVersions, int maxResponseSize) {
180+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
169181
this.jsonMapper = jsonMapper;
170182
this.httpClient = httpClient;
171183
this.requestBuilder = requestBuilder;
@@ -181,6 +193,7 @@ private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient h
181193
.sorted(Comparator.reverseOrder())
182194
.findFirst()
183195
.get();
196+
this.maxResponseSize = maxResponseSize;
184197
}
185198

186199
@Override
@@ -229,7 +242,8 @@ private Publisher<Void> createDelete(String sessionId) {
229242
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
230243
}).flatMap(requestBuilder -> {
231244
var request = requestBuilder.build();
232-
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
245+
return Mono.fromFuture(() -> this.httpClient.sendAsync(request,
246+
ResponseSubscribers.boundedStringBodyHandler(this.maxResponseSize)));
233247
}).then();
234248
}
235249

@@ -470,16 +484,16 @@ private BodyHandler<Void> toSendMessageBodySubscriber(FluxSink<ResponseEvent> si
470484
if (contentType.contains(TEXT_EVENT_STREAM)) {
471485
// For SSE streams, use line subscriber that returns Void
472486
logger.debug("Received SSE stream response, using line subscriber");
473-
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
487+
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink, this.maxResponseSize);
474488
}
475489
else if (contentType.contains(APPLICATION_JSON)) {
476490
// For JSON responses and others, use string subscriber
477491
logger.debug("Received response, using string subscriber");
478-
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
492+
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink, this.maxResponseSize);
479493
}
480494

481495
logger.debug("Received Bodyless response, using discarding subscriber");
482-
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
496+
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink, this.maxResponseSize);
483497
};
484498

485499
return responseBodyHandler;
@@ -754,6 +768,8 @@ public static class Builder {
754768

755769
private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP;
756770

771+
private int maxResponseSize = DEFAULT_MAX_RESPONSE_SIZE;
772+
757773
/**
758774
* Creates a new builder with the specified base URI.
759775
* @param baseUri the base URI of the MCP server
@@ -952,6 +968,26 @@ public Builder supportedProtocolVersions(List<String> supportedProtocolVersions)
952968
return this;
953969
}
954970

971+
/**
972+
* Sets the maximum number of bytes read for a single inbound message, whether it
973+
* arrives on an SSE stream or as a JSON response body. A peer that sends a larger
974+
* message (or never terminates one) has its stream aborted instead of forcing the
975+
* transport to buffer it in memory. Defaults to 16MiB.
976+
*
977+
* <p>
978+
* The bound applies per message, not to the stream as a whole: a long-lived SSE
979+
* stream may deliver any number of messages, each up to this size. SSE field
980+
* framing is allowed a small amount of headroom on top of this size, so a message
981+
* of exactly this many bytes is still accepted.
982+
* @param maxResponseSize the maximum inbound message size, in bytes
983+
* @return this builder
984+
*/
985+
public Builder maxResponseSize(int maxResponseSize) {
986+
Assert.isTrue(maxResponseSize > 0, "maxResponseSize must be positive");
987+
this.maxResponseSize = maxResponseSize;
988+
return this;
989+
}
990+
955991
/**
956992
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
957993
* the current builder configuration.
@@ -961,7 +997,7 @@ public HttpClientStreamableHttpTransport build() {
961997
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
962998
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
963999
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
964-
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
1000+
httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize);
9651001
}
9661002

9671003
}

0 commit comments

Comments
 (0)