Skip to content

Commit 86351d7

Browse files
committed
fix(client): improve performance of large SSE tool responses
Replaced the JDK's slow fromLineSubscriber line-assembly mechanism with a custom byte-level streaming SSE parser SseByteSubscriber. This resolves an O(n^2) bottleneck when parsing large compact JSON payloads that are returned on a single line, improving throughput by ~45x. Closes #1042
1 parent 30f1adf commit 86351d7

2 files changed

Lines changed: 456 additions & 48 deletions

File tree

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

Lines changed: 132 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import java.net.http.HttpResponse;
88
import java.net.http.HttpResponse.BodySubscriber;
99
import java.net.http.HttpResponse.ResponseInfo;
10+
import java.nio.ByteBuffer;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.List;
1013
import java.util.concurrent.atomic.AtomicReference;
1114
import java.util.regex.Pattern;
1215

@@ -56,7 +59,7 @@ record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements
5659

5760
static BodySubscriber<Void> sseToBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
5861
return HttpResponse.BodySubscribers
59-
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink)));
62+
.fromSubscriber(FlowAdapters.toFlowSubscriber(new SseByteSubscriber(responseInfo, sink)));
6063
}
6164

6265
static BodySubscriber<Void> aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
@@ -69,56 +72,33 @@ static BodySubscriber<Void> bodilessBodySubscriber(ResponseInfo responseInfo, Fl
6972
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink)));
7073
}
7174

72-
static class SseLineSubscriber extends BaseSubscriber<String> {
75+
static class SseByteSubscriber extends BaseSubscriber<List<ByteBuffer>> {
7376

74-
/**
75-
* Pattern to extract data content from SSE "data:" lines.
76-
*/
7777
private static final Pattern EVENT_DATA_PATTERN = Pattern.compile("^data:(.+)$", Pattern.MULTILINE);
7878

79-
/**
80-
* Pattern to extract event ID from SSE "id:" lines.
81-
*/
8279
private static final Pattern EVENT_ID_PATTERN = Pattern.compile("^id:(.+)$", Pattern.MULTILINE);
8380

84-
/**
85-
* Pattern to extract event type from SSE "event:" lines.
86-
*/
8781
private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^event:(.+)$", Pattern.MULTILINE);
8882

89-
/**
90-
* The sink for emitting parsed response events.
91-
*/
9283
private final FluxSink<ResponseEvent> sink;
9384

94-
/**
95-
* StringBuilder for accumulating multi-line event data.
96-
*/
9785
private final StringBuilder eventBuilder;
9886

99-
/**
100-
* Current event's ID, if specified.
101-
*/
10287
private final AtomicReference<String> currentEventId;
10388

104-
/**
105-
* Current event's type, if specified.
106-
*/
10789
private final AtomicReference<String> currentEventType;
10890

109-
/**
110-
* The response information from the HTTP response. Send with each event to
111-
* provide context.
112-
*/
113-
private ResponseInfo responseInfo;
91+
private final ResponseInfo responseInfo;
11492

115-
/**
116-
* Creates a new LineSubscriber that will emit parsed SSE events to the provided
117-
* sink.
118-
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
119-
* to
120-
*/
121-
public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
93+
private final SseByteBuffer buffer = new SseByteBuffer();
94+
95+
private volatile boolean hasRequestedDemand = false;
96+
97+
private int scanIndex = 0;
98+
99+
private int start = 0;
100+
101+
public SseByteSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
122102
this.sink = sink;
123103
this.eventBuilder = new StringBuilder();
124104
this.currentEventId = new AtomicReference<>();
@@ -128,21 +108,71 @@ public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink
128108

129109
@Override
130110
protected void hookOnSubscribe(Subscription subscription) {
131-
132111
sink.onRequest(n -> {
133-
subscription.request(n);
112+
if (!hasRequestedDemand) {
113+
subscription.request(Long.MAX_VALUE);
114+
}
115+
hasRequestedDemand = true;
134116
});
135117

136-
// Register disposal callback to cancel subscription when Flux is disposed
137118
sink.onDispose(() -> {
138119
subscription.cancel();
139120
});
140121
}
141122

142123
@Override
143-
protected void hookOnNext(String line) {
124+
protected void hookOnNext(List<ByteBuffer> buffers) {
125+
for (ByteBuffer b : buffers) {
126+
int remaining = b.remaining();
127+
if (remaining > 0) {
128+
byte[] bytes = new byte[remaining];
129+
b.get(bytes);
130+
buffer.append(bytes, 0, remaining);
131+
}
132+
}
133+
parseBuffer();
134+
}
135+
136+
private void parseBuffer() {
137+
byte[] buf = buffer.getBuf();
138+
int count = buffer.getCount();
139+
140+
while (scanIndex < count) {
141+
byte b = buf[scanIndex];
142+
if (b == '\n') {
143+
int lineEnd = scanIndex;
144+
int terminatorLen = 1;
145+
processLine(buf, start, lineEnd);
146+
start = lineEnd + terminatorLen;
147+
scanIndex = start;
148+
}
149+
else if (b == '\r') {
150+
if (scanIndex + 1 < count) {
151+
int lineEnd = scanIndex;
152+
int terminatorLen = (buf[scanIndex + 1] == '\n') ? 2 : 1;
153+
processLine(buf, start, lineEnd);
154+
start = lineEnd + terminatorLen;
155+
scanIndex = start;
156+
}
157+
else {
158+
break;
159+
}
160+
}
161+
else {
162+
scanIndex++;
163+
}
164+
}
165+
166+
if (start > 0) {
167+
buffer.shift(start);
168+
scanIndex -= start;
169+
start = 0;
170+
}
171+
}
172+
173+
private void processLine(byte[] buf, int start, int end) {
174+
String line = new String(buf, start, end - start, StandardCharsets.UTF_8);
144175
if (line.isEmpty()) {
145-
// Empty line means end of event
146176
if (this.eventBuilder.length() > 0) {
147177
String eventData = this.eventBuilder.toString();
148178
SseEvent sseEvent = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
@@ -157,39 +187,47 @@ protected void hookOnNext(String line) {
157187
if (matcher.find()) {
158188
this.eventBuilder.append(matcher.group(1).trim()).append("\n");
159189
}
160-
upstream().request(1);
161190
}
162191
else if (line.startsWith("id:")) {
163192
var matcher = EVENT_ID_PATTERN.matcher(line);
164193
if (matcher.find()) {
165194
this.currentEventId.set(matcher.group(1).trim());
166195
}
167-
upstream().request(1);
168196
}
169197
else if (line.startsWith("event:")) {
170198
var matcher = EVENT_TYPE_PATTERN.matcher(line);
171199
if (matcher.find()) {
172200
this.currentEventType.set(matcher.group(1).trim());
173201
}
174-
upstream().request(1);
175202
}
176203
else if (line.startsWith(":")) {
177-
// Ignore comment lines starting with ":"
178-
// This is a no-op, just to skip comments
179204
logger.debug("Ignoring comment line: {}", line);
180-
upstream().request(1);
181205
}
182206
else {
183-
// If the response is not successful, emit an error
184207
this.sink.error(new McpTransportException(
185208
"Invalid SSE response. Status code: " + this.responseInfo.statusCode() + " Line: " + line));
186-
187209
}
188210
}
189211
}
190212

191213
@Override
192214
protected void hookOnComplete() {
215+
byte[] buf = buffer.getBuf();
216+
int count = buffer.getCount();
217+
218+
// If we broke out of the loop because of a trailing '\r' at the end of the
219+
// stream,
220+
// treat it as a bare '\r' line terminator now.
221+
if (scanIndex < count && buf[scanIndex] == '\r') {
222+
int lineEnd = scanIndex;
223+
int terminatorLen = 1;
224+
processLine(buf, start, lineEnd);
225+
start = lineEnd + terminatorLen;
226+
}
227+
228+
if (start < count) {
229+
processLine(buf, start, count);
230+
}
193231
if (this.eventBuilder.length() > 0) {
194232
String eventData = this.eventBuilder.toString();
195233
SseEvent sseEvent = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
@@ -205,6 +243,52 @@ protected void hookOnError(Throwable throwable) {
205243

206244
}
207245

246+
private static class SseByteBuffer {
247+
248+
private byte[] buf = new byte[4096];
249+
250+
private int count = 0;
251+
252+
public void append(byte[] b, int off, int len) {
253+
ensureCapacity(count + len);
254+
System.arraycopy(b, off, buf, count, len);
255+
count += len;
256+
}
257+
258+
private void ensureCapacity(int minCapacity) {
259+
if (minCapacity - buf.length > 0) {
260+
int newCapacity = buf.length * 2;
261+
if (newCapacity - minCapacity < 0) {
262+
newCapacity = minCapacity;
263+
}
264+
byte[] newBuf = new byte[newCapacity];
265+
System.arraycopy(buf, 0, newBuf, 0, count);
266+
buf = newBuf;
267+
}
268+
}
269+
270+
public byte[] getBuf() {
271+
return buf;
272+
}
273+
274+
public int getCount() {
275+
return count;
276+
}
277+
278+
public void shift(int bytesToShift) {
279+
if (bytesToShift <= 0) {
280+
return;
281+
}
282+
if (bytesToShift >= count) {
283+
count = 0;
284+
return;
285+
}
286+
System.arraycopy(buf, bytesToShift, buf, 0, count - bytesToShift);
287+
count -= bytesToShift;
288+
}
289+
290+
}
291+
208292
static class AggregateSubscriber extends BaseSubscriber<String> {
209293

210294
/**

0 commit comments

Comments
 (0)