Skip to content

Commit eb4745f

Browse files
committed
Polish PR
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 5b40c01 commit eb4745f

10 files changed

Lines changed: 298 additions & 228 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,10 @@ public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> h
393393
ResponseSubscribers.boundedPublisherBodyHandler(this.maxResponseSize)))
394394
.flatMapMany(response -> {
395395
if (isClosing) {
396-
return Flux.empty();
396+
// The body is handed over as a publisher and nothing is read off
397+
// the wire until it is subscribed, so it has to be drained even
398+
// when its content is of no further interest.
399+
return ResponseSubscribers.drain(response.body());
397400
}
398401

399402
int statusCode = response.statusCode();

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ private Flux<McpSchema.JSONRPCMessage> consumeSseStream(
286286
return ResponseSubscribers.decodeSseResponse(lines, this.maxResponseSize).flatMap(sseEvent -> {
287287
if (!isMessageEvent(sseEvent.event())) {
288288
logger.debug("Received SSE event with type: {}", sseEvent);
289+
if (onFirstMessage != null) {
290+
onFirstMessage.run();
291+
}
289292
return Flux.empty();
290293
}
291294
String data = sseEvent.data();
@@ -536,8 +539,6 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
536539
ResponseSubscribers.boundedPublisherBodyHandler(this.maxResponseSize)))
537540
.flatMapMany(httpResponse -> {
538541
int statusCode = httpResponse.statusCode();
539-
Exception exception = null;
540-
boolean proceed = false;
541542
Optional<String> maybeSessionId = transportSession == null ? Optional.empty()
542543
: transportSession.sessionId();
543544
if (statusCode == 401 || statusCode == 403) {
@@ -552,8 +553,7 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
552553
}
553554

554555
if (transportSession
555-
.markInitialized(httpResponse.headers().firstValue("mcp-session-id").orElse(null))
556-
&& !openConnectionOnStartup) {
556+
.markInitialized(httpResponse.headers().firstValue("mcp-session-id").orElse(null))) {
557557
reconnect(null).contextWrite(deliveredSink.contextView()).subscribe();
558558
}
559559

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

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import java.nio.charset.CharacterCodingException;
1313
import java.nio.charset.CharsetDecoder;
1414
import java.nio.charset.CoderResult;
15+
import java.nio.charset.CodingErrorAction;
1516
import java.nio.charset.StandardCharsets;
1617
import java.util.ArrayList;
1718
import java.util.List;
@@ -180,12 +181,25 @@ static <T> Flux<T> drain(Publisher<List<ByteBuffer>> body) {
180181

181182
/**
182183
* Stateful UTF-8 decoder that splits a stream of byte-buffer chunks into complete
183-
* lines. Handles multi-byte characters split across chunk boundaries, and both
184-
* {@code "\n"} and {@code "\r\n"} terminators.
184+
* lines. Handles multi-byte characters split across chunk boundaries, and terminates
185+
* a line on {@code "\r\n"}, {@code "\r"} or {@code "\n"} alike, as the SSE wire
186+
* format does. Bytes that do not decode are replaced rather than reported, so a peer
187+
* sending one does not cost the stream.
185188
*/
186189
static final class Utf8LineDecoder {
187190

188-
private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
191+
/**
192+
* Undecodable input costs one replacement character rather than the stream: a
193+
* decoder left on the default {@link CodingErrorAction#REPORT} fails the whole
194+
* response over a single byte a peer mangled, and takes with it the lines already
195+
* decoded from the same chunk, because {@link #decode(List)} throws instead of
196+
* returning them. A body cut short mid-character is enough to hit it. This
197+
* matches {@link java.net.http.HttpResponse.BodySubscribers#fromLineSubscriber},
198+
* the path this decoder replaces, which configured the same two actions.
199+
*/
200+
private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
201+
.onMalformedInput(CodingErrorAction.REPLACE)
202+
.onUnmappableCharacter(CodingErrorAction.REPLACE);
189203

190204
private final CharBuffer charBuffer = CharBuffer.allocate(4096);
191205

@@ -202,6 +216,14 @@ static final class Utf8LineDecoder {
202216
*/
203217
private int scannedForLineTerminator = 0;
204218

219+
/**
220+
* Whether the line just emitted was terminated by a CR, so that a LF opening what
221+
* follows completes that terminator instead of ending a line of its own. A CR is
222+
* emitted on as soon as it arrives, before it is known whether a LF follows it,
223+
* and the two may be split across chunks.
224+
*/
225+
private boolean crTerminatedPreviousLine = false;
226+
205227
// Holds partial UTF-8 sequences left over from a previous chunk (max 3 bytes
206228
// for a BMP code point; 4 bytes for a supplementary one).
207229
private ByteBuffer pendingBytes = ByteBuffer.allocate(0);
@@ -221,6 +243,9 @@ List<String> decode(List<ByteBuffer> chunk) {
221243
CoderResult result = decoder.decode(input, charBuffer, false);
222244
drainCharBuffer();
223245
extractCompletedLines(lines);
246+
// Unreachable while the decoder replaces undecodable input, but kept
247+
// so that an error result cannot spin this loop: it is neither an
248+
// underflow nor an overflow.
224249
if (result.isError()) {
225250
try {
226251
result.throwException();
@@ -270,13 +295,11 @@ List<String> flush() {
270295
extractCompletedLines(lines);
271296
if (leftover.length() > 0) {
272297
String last = leftover.toString();
273-
if (last.endsWith("\r")) {
274-
last = last.substring(0, last.length() - 1);
275-
}
276298
leftover.setLength(0);
277299
this.scannedForLineTerminator = 0;
278300
lines.add(last);
279301
}
302+
this.crTerminatedPreviousLine = false;
280303
return lines;
281304
}
282305

@@ -287,19 +310,43 @@ private void drainCharBuffer() {
287310
}
288311

289312
private void extractCompletedLines(List<String> out) {
290-
int newlineIdx;
291-
while ((newlineIdx = leftover.indexOf("\n", this.scannedForLineTerminator)) != -1) {
292-
String line = leftover.substring(0, newlineIdx);
293-
if (line.endsWith("\r")) {
294-
line = line.substring(0, line.length() - 1);
313+
while (true) {
314+
if (this.crTerminatedPreviousLine) {
315+
if (leftover.length() == 0) {
316+
// The LF, if there is one, is in a chunk that has not arrived.
317+
return;
318+
}
319+
if (leftover.charAt(0) == '\n') {
320+
leftover.delete(0, 1);
321+
}
322+
this.crTerminatedPreviousLine = false;
295323
}
296-
out.add(line);
297-
leftover.delete(0, newlineIdx + 1);
324+
int terminatorIdx = indexOfLineTerminator(this.scannedForLineTerminator);
325+
if (terminatorIdx == -1) {
326+
this.scannedForLineTerminator = leftover.length();
327+
return;
328+
}
329+
out.add(leftover.substring(0, terminatorIdx));
330+
this.crTerminatedPreviousLine = leftover.charAt(terminatorIdx) == '\r';
331+
leftover.delete(0, terminatorIdx + 1);
298332
// What is left starts after the terminator, so none of it has been
299333
// searched yet.
300334
this.scannedForLineTerminator = 0;
301335
}
302-
this.scannedForLineTerminator = leftover.length();
336+
}
337+
338+
/**
339+
* Index of the first CR or LF in {@link #leftover} at or after {@code from}, or
340+
* {@code -1} when there is none.
341+
*/
342+
private int indexOfLineTerminator(int from) {
343+
for (int i = from; i < leftover.length(); i++) {
344+
char c = leftover.charAt(i);
345+
if (c == '\n' || c == '\r') {
346+
return i;
347+
}
348+
}
349+
return -1;
303350
}
304351

305352
}
@@ -479,7 +526,7 @@ public void onComplete() {
479526

480527
/**
481528
* A {@link BoundedBodySubscriber} that aborts the response once a single line (a run
482-
* of bytes with no LF) exceeds {@code maxSize} bytes.
529+
* of bytes with no line terminator) exceeds {@code maxSize} bytes.
483530
*
484531
* <p>
485532
* {@link Utf8LineDecoder} buffers characters until it encounters a line terminator,
@@ -488,9 +535,10 @@ public void onComplete() {
488535
* wire and cancels the subscription before that buffer can grow without bound.
489536
*
490537
* <p>
491-
* Only LF resets the count, because LF is the only byte {@link Utf8LineDecoder}
492-
* flushes a line on: a lone CR leaves the decoder's buffer growing, so it must not
493-
* refill this budget either. CRLF still resets, on its LF.
538+
* CR and LF both reset the count, matching the terminators {@link Utf8LineDecoder}
539+
* flushes a line on: whatever empties the decoder's buffer has to refill this budget,
540+
* or a peer framing short lines with CR alone would be aborted for exceeding a bound
541+
* its lines never reach. A CRLF resets twice, which is harmless.
494542
*/
495543
static final class BoundedLineBodySubscriber<T> extends BoundedBodySubscriber<T> {
496544

@@ -518,7 +566,7 @@ protected boolean checkSize(ByteBuffer buffer) {
518566
// The limit is within reach, so account for every line exactly.
519567
for (int i = position; i < limit; i++) {
520568
byte b = buffer.get(i);
521-
if (b == '\n') {
569+
if (b == '\n' || b == '\r') {
522570
this.bytesSinceLineTerminator = 0;
523571
}
524572
else if (++this.bytesSinceLineTerminator > this.maxSize) {
@@ -529,12 +577,13 @@ else if (++this.bytesSinceLineTerminator > this.maxSize) {
529577
}
530578

531579
/**
532-
* Returns the number of bytes after the last LF in the buffer, or the whole span
533-
* added to the running count when the buffer holds no LF.
580+
* Returns the number of bytes after the last line terminator in the buffer, or
581+
* the whole span added to the running count when the buffer holds none.
534582
*/
535583
private long lengthOfTrailingRun(ByteBuffer buffer, int position, int limit) {
536584
for (int i = limit - 1; i >= position; i--) {
537-
if (buffer.get(i) == '\n') {
585+
byte b = buffer.get(i);
586+
if (b == '\n' || b == '\r') {
538587
return limit - 1 - i;
539588
}
540589
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,18 @@ void lineSubscriberHandlesCrLfSplitAcrossBuffers() {
107107
}
108108

109109
@Test
110-
void lineSubscriberIsNotResetByLoneCarriageReturns() {
110+
void lineSubscriberIsResetByLoneCarriageReturns() {
111111
BoundedLineBodySubscriber<Void> subscriber = lineSubscriber();
112112

113-
// Utf8LineDecoder only flushes a line on LF, so a peer streaming CR-terminated
114-
// runs keeps its buffer growing. A lone CR must not refill the budget.
113+
// A lone CR terminates a line, so it flushes Utf8LineDecoder's buffer and has to
114+
// refill the budget here too. Otherwise a peer framing short lines with CR alone
115+
// has its response aborted for exceeding a bound its lines never reach.
115116
boolean accepted = true;
116117
for (int i = 0; i < 10 && accepted; i++) {
117118
accepted = subscriber.checkSize(buffer("aa\r"));
118119
}
119120

120-
assertThat(accepted).isFalse();
121+
assertThat(accepted).isTrue();
121122
}
122123

123124
@Test
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client.transport;
6+
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.function.Function;
10+
import java.util.stream.Stream;
11+
12+
import io.modelcontextprotocol.spec.McpClientTransport;
13+
import io.modelcontextprotocol.spec.McpSchema;
14+
import io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapper;
15+
import org.junit.jupiter.params.ParameterizedTest;
16+
import org.junit.jupiter.params.provider.Arguments;
17+
import org.junit.jupiter.params.provider.MethodSource;
18+
import reactor.test.StepVerifier;
19+
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
import static org.junit.jupiter.api.Named.named;
22+
import static org.junit.jupiter.params.provider.Arguments.arguments;
23+
24+
class HttpClientHttpTransportLeakTests {
25+
26+
static int selectorManagerThreadCount() {
27+
return selectorManagerThreadNames().size();
28+
}
29+
30+
static List<String> selectorManagerThreadNames() {
31+
return Thread.getAllStackTraces()
32+
.keySet()
33+
.stream()
34+
.map(Thread::getName)
35+
.filter(name -> name.contains("HttpClient") && name.contains("SelectorManager"))
36+
.sorted()
37+
.toList();
38+
}
39+
40+
static int forceGcUntilStable() throws InterruptedException {
41+
int previousCount = Integer.MAX_VALUE;
42+
int stableIterations = 0;
43+
int currentCount = previousCount;
44+
45+
for (int i = 0; i < 40; i++) {
46+
System.gc();
47+
System.runFinalization();
48+
Thread.sleep(250);
49+
50+
currentCount = selectorManagerThreadCount();
51+
if (currentCount == previousCount) {
52+
stableIterations++;
53+
if (stableIterations >= 4) {
54+
break;
55+
}
56+
}
57+
else {
58+
stableIterations = 0;
59+
previousCount = currentCount;
60+
}
61+
}
62+
63+
return currentCount;
64+
}
65+
66+
static void pauseForSelectorStartup() throws InterruptedException {
67+
Thread.sleep(150);
68+
}
69+
70+
@ParameterizedTest
71+
@MethodSource("httpTransports")
72+
void closeDoesNotRetainOwnedHttpClient(Function<String, McpClientTransport> httpTransportBuilder) throws Exception {
73+
try (LoopbackMcpHttpServer server = LoopbackMcpHttpServer.start()) {
74+
int selectorThreadsBefore = selectorManagerThreadCount();
75+
Function<reactor.core.publisher.Mono<McpSchema.JSONRPCMessage>, reactor.core.publisher.Mono<McpSchema.JSONRPCMessage>> handler = Function
76+
.identity();
77+
78+
for (int i = 0; i < 12; i++) {
79+
McpClientTransport transport = httpTransportBuilder.apply(server.baseUri().toString());
80+
81+
StepVerifier.create(transport.connect(handler)).verifyComplete();
82+
StepVerifier.create(transport.sendMessage(
83+
new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, "ping", Map.of("iteration", i))))
84+
.verifyComplete();
85+
pauseForSelectorStartup();
86+
StepVerifier.create(transport.closeGracefully()).verifyComplete();
87+
}
88+
89+
int selectorThreadsAfter = forceGcUntilStable();
90+
91+
assertThat(selectorThreadsAfter)
92+
.describedAs(
93+
"closed transports should not keep owned HttpClient instances alive, remaining threads: %s",
94+
selectorManagerThreadNames())
95+
.isLessThanOrEqualTo(selectorThreadsBefore + 1);
96+
}
97+
}
98+
99+
static Stream<Arguments> httpTransports() {
100+
Function<String, McpClientTransport> streamableHttp = (
101+
uri) -> HttpClientStreamableHttpTransport.builder(uri).jsonMapper(new GsonMcpJsonMapper()).build();
102+
Function<String, McpClientTransport> sse = (
103+
uri) -> HttpClientSseClientTransport.builder(uri).jsonMapper(new GsonMcpJsonMapper()).build();
104+
return Stream.of(arguments(named("Streamable HTTP", streamableHttp)), arguments(named("SSE", sse)));
105+
}
106+
107+
}

0 commit comments

Comments
 (0)