Skip to content

Commit 22866e8

Browse files
committed
Close POST-related streams in McpStreamableServerSession
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 9eb1d26 commit 22866e8

2 files changed

Lines changed: 177 additions & 30 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java‎

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,25 @@
55
package io.modelcontextprotocol.spec;
66

77
import java.time.Duration;
8+
import java.util.List;
89
import java.util.Map;
10+
import java.util.Set;
911
import java.util.UUID;
1012
import java.util.concurrent.ConcurrentHashMap;
1113
import java.util.concurrent.atomic.AtomicLong;
1214
import java.util.concurrent.atomic.AtomicReference;
1315
import java.util.function.Supplier;
1416

15-
import org.slf4j.Logger;
16-
import org.slf4j.LoggerFactory;
17-
18-
import io.modelcontextprotocol.json.TypeRef;
19-
2017
import io.modelcontextprotocol.common.McpTransportContext;
18+
import io.modelcontextprotocol.json.TypeRef;
2119
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
2220
import io.modelcontextprotocol.server.McpAsyncServerExchange;
2321
import io.modelcontextprotocol.server.McpNotificationHandler;
2422
import io.modelcontextprotocol.server.McpRequestHandler;
2523
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
2624
import io.modelcontextprotocol.util.Assert;
25+
import org.slf4j.Logger;
26+
import org.slf4j.LoggerFactory;
2727
import reactor.core.publisher.Flux;
2828
import reactor.core.publisher.Mono;
2929
import reactor.core.publisher.MonoSink;
@@ -43,6 +43,12 @@ public class McpStreamableServerSession implements McpLoggableSession {
4343

4444
private final ConcurrentHashMap<Object, McpStreamableServerSessionStream> requestIdToStream = new ConcurrentHashMap<>();
4545

46+
/**
47+
* Every stream with a connection currently attached, whether the listening stream or
48+
* a POST response stream, so that they can all be released when the session ends.
49+
*/
50+
private final Set<McpStreamableServerSessionStream> openStreams = ConcurrentHashMap.newKeySet();
51+
4652
private final String id;
4753

4854
private final Duration requestTimeout;
@@ -201,12 +207,24 @@ public McpStreamableServerSessionStream listeningStream(McpStreamableServerTrans
201207
McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream(transport);
202208
McpLoggableSession replaced = this.listeningStreamRef.getAndSet(listeningStream);
203209
if (replaced instanceof McpStreamableServerSessionStream replacedStream) {
204-
logger.debug("Closing the listening stream replaced in session {}", this.id);
205-
replacedStream.close();
210+
logger.debug("Releasing the connection of the listening stream replaced in session {}", this.id);
211+
replacedStream.releaseTransport();
206212
}
207213
return listeningStream;
208214
}
209215

216+
/**
217+
* Releases the connection of the listening stream, if one is attached, leaving the
218+
* session without one until the client establishes a new stream. Used when the
219+
* connection turns out to be dead, typically because a keep-alive ping went
220+
* unanswered, so that the socket behind it is not held on to for nothing.
221+
*/
222+
public void releaseListeningStream() {
223+
if (this.listeningStreamRef.get() instanceof McpStreamableServerSessionStream stream) {
224+
stream.releaseTransport();
225+
}
226+
}
227+
210228
// TODO: keep track of history by keeping a map from eventId to stream and then
211229
// iterate over the events using the lastEventId
212230
public Flux<McpSchema.JSONRPCMessage> replay(Object lastEventId) {
@@ -226,9 +244,6 @@ public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
226244
McpStreamableServerSessionStream stream = new McpStreamableServerSessionStream(transport);
227245
McpRequestHandler<?> requestHandler = McpStreamableServerSession.this.requestHandlers
228246
.get(jsonrpcRequest.method());
229-
// TODO: delegate to stream, which upon successful response should close
230-
// remove itself from the registry and also close the underlying transport
231-
// (sink)
232247
if (requestHandler == null) {
233248
MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method());
234249
return transport
@@ -237,7 +252,7 @@ public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
237252
.error(jsonrpcRequest.id(),
238253
new McpSchema.JSONRPCResponse.JSONRPCError(
239254
McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data())))
240-
.then(transport.closeGracefully());
255+
.then(stream.closeGracefully());
241256
}
242257
return requestHandler
243258
.handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(),
@@ -253,7 +268,7 @@ public Mono<Void> responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStr
253268
return Mono.just(errorResponse);
254269
})
255270
.flatMap(transport::sendMessage)
256-
.then(transport.closeGracefully());
271+
.then(stream.closeGracefully());
257272
});
258273
}
259274

@@ -324,20 +339,18 @@ private MethodNotFoundError getMethodNotFoundError(String method) {
324339
@Override
325340
public Mono<Void> closeGracefully() {
326341
return this.onClose.get().onErrorComplete().then(Mono.defer(() -> {
327-
McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
328-
return listeningStream.closeGracefully();
329-
// TODO: Also close all the open streams
342+
this.listeningStreamRef.set(this.missingMcpTransportSession);
343+
return Flux.fromIterable(List.copyOf(this.openStreams))
344+
.flatMap(McpStreamableServerSessionStream::closeGracefully)
345+
.then();
330346
}));
331347
}
332348

333349
@Override
334350
public void close() {
335351
this.onClose.get().onErrorComplete().subscribe();
336-
McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession);
337-
if (listeningStream != null) {
338-
listeningStream.close();
339-
}
340-
// TODO: Also close all open streams
352+
this.listeningStreamRef.set(this.missingMcpTransportSession);
353+
List.copyOf(this.openStreams).forEach(McpStreamableServerSessionStream::close);
341354
}
342355

343356
/**
@@ -387,18 +400,19 @@ public final class McpStreamableServerSessionStream implements McpLoggableSessio
387400

388401
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
389402

390-
private final McpStreamableServerTransport transport;
403+
private final McpStreamableServerTransport connection;
391404

392405
private final String transportId;
393406

394407
private final Supplier<String> uuidGenerator;
395408

396409
/**
397410
* Constructor accepting the dedicated transport representing the SSE stream.
398-
* @param transport request-specific SSE transport stream
411+
* @param connection request-specific SSE transport stream
399412
*/
400-
public McpStreamableServerSessionStream(McpStreamableServerTransport transport) {
401-
this.transport = transport;
413+
public McpStreamableServerSessionStream(McpStreamableServerTransport connection) {
414+
this.connection = connection;
415+
McpStreamableServerSession.this.openStreams.add(this);
402416
this.transportId = UUID.randomUUID().toString();
403417
// This ID design allows for a constant-time extraction of the history by
404418
// precisely identifying the SSE stream using the first component
@@ -428,9 +442,11 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
428442
requestParams);
429443
String messageId = this.uuidGenerator.get();
430444
// TODO: store message in history
431-
this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {
445+
this.connection.sendMessage(jsonrpcRequest, messageId).subscribe(v -> {
432446
}, sink::error);
433-
}).timeout(requestTimeout).doOnError(e -> {
447+
}).timeout(requestTimeout).doFinally(signal -> {
448+
// Also on completion and cancellation: a resolved request keeps no state,
449+
// and a deadline imposed by the caller cancels rather than errors
434450
this.pendingResponses.remove(requestId);
435451
McpStreamableServerSession.this.requestIdToStream.remove(requestId);
436452
}).handle((jsonRpcResponse, sink) -> {
@@ -442,7 +458,7 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
442458
sink.complete();
443459
}
444460
else {
445-
sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef));
461+
sink.next(this.connection.unmarshalFrom(jsonRpcResponse.result(), typeRef));
446462
}
447463
}
448464
});
@@ -453,31 +469,51 @@ public Mono<Void> sendNotification(String method, Object params) {
453469
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params);
454470
String messageId = this.uuidGenerator.get();
455471
// TODO: store message in history
456-
return this.transport.sendMessage(jsonrpcNotification, messageId);
472+
return this.connection.sendMessage(jsonrpcNotification, messageId);
457473
}
458474

459475
@Override
460476
public Mono<Void> closeGracefully() {
461477
return Mono.defer(() -> {
478+
McpStreamableServerSession.this.openStreams.remove(this);
462479
this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
463480
this.pendingResponses.clear();
464481
// If this was the generic stream, reset it
465482
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
466483
McpStreamableServerSession.this.missingMcpTransportSession);
467484
McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
468-
return this.transport.closeGracefully();
485+
return this.connection.closeGracefully();
469486
});
470487
}
471488

472489
@Override
473490
public void close() {
491+
McpStreamableServerSession.this.openStreams.remove(this);
474492
this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
475493
this.pendingResponses.clear();
476494
// If this was the generic stream, reset it
477495
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
478496
McpStreamableServerSession.this.missingMcpTransportSession);
479497
McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
480-
this.transport.close();
498+
this.connection.close();
499+
}
500+
501+
/**
502+
* Releases the connection carrying this stream, detaching the stream from the
503+
* session, but keeps its pending server-initiated requests resolvable: the client
504+
* answers those with a separate HTTP POST request, which outlives the SSE stream
505+
* the request was sent on.
506+
* <p>
507+
* This is the counterpart of {@link #close()} for the end of a connection rather
508+
* than the end of the session: an SSE stream going away, whether replaced,
509+
* disconnected or timed out, does not invalidate the requests sent on it.
510+
*/
511+
public void releaseTransport() {
512+
McpStreamableServerSession.this.openStreams.remove(this);
513+
// If this was the generic stream, reset it
514+
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
515+
McpStreamableServerSession.this.missingMcpTransportSession);
516+
this.connection.close();
481517
}
482518

483519
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright 2024-2025 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.spec;
6+
7+
import java.time.Duration;
8+
import java.util.Map;
9+
import java.util.Queue;
10+
import java.util.concurrent.ConcurrentLinkedQueue;
11+
12+
import io.modelcontextprotocol.json.TypeRef;
13+
import org.junit.jupiter.api.Test;
14+
import reactor.core.publisher.Mono;
15+
16+
import static org.assertj.core.api.Assertions.assertThat;
17+
18+
/**
19+
* Tests for {@link McpStreamableServerSession}.
20+
*/
21+
class McpStreamableServerSessionTests {
22+
23+
private static final Duration TIMEOUT = Duration.ofSeconds(5);
24+
25+
private McpStreamableServerSession session() {
26+
return new McpStreamableServerSession("session-1", McpSchema.ClientCapabilities.builder().build(),
27+
new McpSchema.Implementation("test-client", "1.0.0"), TIMEOUT, Map.of(), Map.of());
28+
}
29+
30+
@Test
31+
void replacedListeningStreamHasItsConnectionReleased() {
32+
var session = session();
33+
var firstTransport = new RecordingTransport();
34+
35+
session.listeningStream(firstTransport);
36+
assertThat(firstTransport.closed).isFalse();
37+
38+
session.listeningStream(new RecordingTransport());
39+
assertThat(firstTransport.closed).isTrue();
40+
}
41+
42+
@Test
43+
void replacingListeningStreamKeepsItsPendingRequestsResolvable() {
44+
var session = session();
45+
var firstTransport = new RecordingTransport();
46+
session.listeningStream(firstTransport);
47+
48+
// Server-initiated requests (sampling, elicitation, roots/list) are sent on the
49+
// listening SSE stream, but the client answers them with a separate HTTP POST
50+
// which outlives that stream.
51+
var pending = session.sendRequest("sampling/createMessage", null, new TypeRef<String>() {
52+
}).toFuture();
53+
assertThat(firstTransport.sent).hasSize(1);
54+
var requestId = ((McpSchema.JSONRPCRequest) firstTransport.sent.peek()).id();
55+
56+
// The client reconnects with a Last-Event-ID header, replacing the listening
57+
// stream. The request sent on the replaced stream must stay pending.
58+
session.listeningStream(new RecordingTransport());
59+
assertThat(pending).isNotDone();
60+
61+
session.accept(McpSchema.JSONRPCResponse.result(requestId, "response-value")).block(TIMEOUT);
62+
63+
assertThat(pending).succeedsWithin(TIMEOUT).isEqualTo("response-value");
64+
}
65+
66+
@Test
67+
void closingTheSessionReleasesTheConnectionOfItsStreams() {
68+
var session = session();
69+
var transport = new RecordingTransport();
70+
session.listeningStream(transport);
71+
72+
session.close();
73+
74+
assertThat(transport.closed).isTrue();
75+
}
76+
77+
static class RecordingTransport implements McpStreamableServerTransport {
78+
79+
final Queue<McpSchema.JSONRPCMessage> sent = new ConcurrentLinkedQueue<>();
80+
81+
volatile boolean closed;
82+
83+
@Override
84+
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId) {
85+
return Mono.fromRunnable(() -> this.sent.add(message));
86+
}
87+
88+
@Override
89+
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
90+
return sendMessage(message, null);
91+
}
92+
93+
@Override
94+
public Mono<Void> closeGracefully() {
95+
return Mono.fromRunnable(() -> this.closed = true);
96+
}
97+
98+
@Override
99+
public void close() {
100+
this.closed = true;
101+
}
102+
103+
@SuppressWarnings("unchecked")
104+
@Override
105+
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
106+
return (T) data;
107+
}
108+
109+
}
110+
111+
}

0 commit comments

Comments
 (0)