Skip to content

Commit fe4e821

Browse files
committed
Error pending requests on session close
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 4a152c0 commit fe4e821

2 files changed

Lines changed: 125 additions & 27 deletions

File tree

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

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

77
import java.time.Duration;
8+
import java.util.ArrayList;
89
import java.util.List;
910
import java.util.Map;
1011
import java.util.Set;
1112
import java.util.UUID;
1213
import java.util.concurrent.ConcurrentHashMap;
1314
import java.util.concurrent.atomic.AtomicLong;
1415
import java.util.concurrent.atomic.AtomicReference;
16+
import java.util.function.Predicate;
1517
import java.util.function.Supplier;
1618

1719
import io.modelcontextprotocol.common.McpTransportContext;
@@ -41,7 +43,14 @@ public class McpStreamableServerSession implements McpLoggableSession {
4143

4244
private static final Logger logger = LoggerFactory.getLogger(McpStreamableServerSession.class);
4345

44-
private final ConcurrentHashMap<String, McpStreamableServerSessionStream> requestIdToStream = new ConcurrentHashMap<>();
46+
/**
47+
* Every server-initiated request still awaiting its response, keyed by request ID.
48+
* Tracked per session rather than per stream, because the client answers over a
49+
* separate HTTP POST which outlives the stream the request was sent on: a stream
50+
* going away must not lose the requests it carried, but the end of the session must
51+
* resolve all of them.
52+
*/
53+
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
4554

4655
/**
4756
* Every stream with a connection currently attached, whether the listening stream or
@@ -313,22 +322,13 @@ public Mono<Void> accept(McpSchema.JSONRPCResponse response) {
313322
logger.debug("Received response: {}", response);
314323

315324
if (response.id() != null) {
316-
var stream = this.requestIdToStream.get(response.id());
317-
if (stream == null) {
318-
return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR)
319-
.message("Unexpected response for unknown id " + response.id())
320-
.build());
321-
}
322-
// TODO: encapsulate this inside the stream itself
323-
var sink = stream.pendingResponses.remove(response.id());
324-
if (sink == null) {
325+
var pendingRequest = this.pendingRequests.remove(response.id());
326+
if (pendingRequest == null) {
325327
return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR)
326328
.message("Unexpected response for unknown id " + response.id())
327329
.build());
328330
}
329-
else {
330-
sink.success(response);
331-
}
331+
pendingRequest.sink().success(response);
332332
}
333333
else {
334334
logger.error("Discarded MCP request response without session id. "
@@ -346,13 +346,38 @@ private MethodNotFoundError getMethodNotFoundError(String method) {
346346
return new MethodNotFoundError(method, "Method not found: " + method, null);
347347
}
348348

349+
/**
350+
* Fails the matching pending requests with the given reason. Each request is removed
351+
* before its sink is failed, so that the cleanup the failure triggers on the
352+
* requesting side finds nothing left to remove.
353+
* @param match selects the requests to fail
354+
* @param reason the message of the error the sinks are failed with
355+
*/
356+
private void failPendingRequests(Predicate<PendingRequest> match, String reason) {
357+
List<String> requestIds = new ArrayList<>();
358+
this.pendingRequests.forEach((requestId, pendingRequest) -> {
359+
if (match.test(pendingRequest)) {
360+
requestIds.add(requestId);
361+
}
362+
});
363+
for (String requestId : requestIds) {
364+
PendingRequest pendingRequest = this.pendingRequests.remove(requestId);
365+
if (pendingRequest != null) {
366+
pendingRequest.sink().error(new RuntimeException(reason));
367+
}
368+
}
369+
}
370+
349371
@Override
350372
public Mono<Void> closeGracefully() {
351373
return this.onClose.get().onErrorComplete().then(Mono.defer(() -> {
352374
this.listeningStreamRef.set(this.missingMcpTransportSession);
353375
return Flux.fromIterable(List.copyOf(this.openStreams))
354376
.flatMap(McpStreamableServerSessionStream::closeGracefully)
355-
.then();
377+
.then()
378+
// Also the requests of the streams already released: the session ending
379+
// is the point at which no client can answer them anymore
380+
.then(Mono.fromRunnable(() -> this.failPendingRequests(request -> true, "Session closed")));
356381
}));
357382
}
358383

@@ -361,6 +386,9 @@ public void close() {
361386
this.onClose.get().onErrorComplete().subscribe();
362387
this.listeningStreamRef.set(this.missingMcpTransportSession);
363388
List.copyOf(this.openStreams).forEach(McpStreamableServerSessionStream::close);
389+
// Also the requests of the streams already released: the session ending is the
390+
// point at which no client can answer them anymore
391+
this.failPendingRequests(request -> true, "Session closed");
364392
}
365393

366394
/**
@@ -402,14 +430,22 @@ public record McpStreamableServerSessionInit(McpStreamableServerSession session,
402430
Mono<McpSchema.InitializeResult> initResult) {
403431
}
404432

433+
/**
434+
* A server-initiated request awaiting its response, along with the stream it was sent
435+
* on.
436+
*
437+
* @param stream the stream the request was sent on
438+
* @param sink the sink to resolve once the response arrives
439+
*/
440+
private record PendingRequest(McpStreamableServerSessionStream stream, MonoSink<McpSchema.JSONRPCResponse> sink) {
441+
}
442+
405443
/**
406444
* An individual SSE stream within a Streamable HTTP context. Can be either the
407445
* listening GET SSE stream or a request-specific POST SSE stream.
408446
*/
409447
public final class McpStreamableServerSessionStream implements McpLoggableSession {
410448

411-
private final ConcurrentHashMap<String, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
412-
413449
private final McpStreamableServerTransport connection;
414450

415451
private final String transportId;
@@ -444,10 +480,10 @@ public boolean isNotificationForLevelAllowed(McpSchema.LoggingLevel loggingLevel
444480
public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> typeRef) {
445481
String requestId = McpStreamableServerSession.this.generateRequestId();
446482

447-
McpStreamableServerSession.this.requestIdToStream.put(requestId, this);
448-
449483
return Mono.<McpSchema.JSONRPCResponse>create(sink -> {
450-
this.pendingResponses.put(requestId, sink);
484+
// Registered on subscription rather than on assembly, so that a request
485+
// which is never sent does not leave the session tracking it forever
486+
McpStreamableServerSession.this.pendingRequests.put(requestId, new PendingRequest(this, sink));
451487
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId,
452488
requestParams);
453489
String messageId = this.uuidGenerator.get();
@@ -457,8 +493,7 @@ public <T> Mono<T> sendRequest(String method, Object requestParams, TypeRef<T> t
457493
}).timeout(requestTimeout).doFinally(signal -> {
458494
// Also on completion and cancellation: a resolved request keeps no state,
459495
// and a deadline imposed by the caller cancels rather than errors
460-
this.pendingResponses.remove(requestId);
461-
McpStreamableServerSession.this.requestIdToStream.remove(requestId);
496+
McpStreamableServerSession.this.pendingRequests.remove(requestId);
462497
}).handle((jsonRpcResponse, sink) -> {
463498
if (jsonRpcResponse.error() != null) {
464499
sink.error(new McpError(jsonRpcResponse.error()));
@@ -486,25 +521,23 @@ public Mono<Void> sendNotification(String method, Object params) {
486521
public Mono<Void> closeGracefully() {
487522
return Mono.defer(() -> {
488523
McpStreamableServerSession.this.openStreams.remove(this);
489-
this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
490-
this.pendingResponses.clear();
524+
McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()),
525+
"Stream closed");
491526
// If this was the generic stream, reset it
492527
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
493528
McpStreamableServerSession.this.missingMcpTransportSession);
494-
McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
495529
return this.connection.closeGracefully();
496530
});
497531
}
498532

499533
@Override
500534
public void close() {
501535
McpStreamableServerSession.this.openStreams.remove(this);
502-
this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed")));
503-
this.pendingResponses.clear();
536+
McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()),
537+
"Stream closed");
504538
// If this was the generic stream, reset it
505539
McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this,
506540
McpStreamableServerSession.this.missingMcpTransportSession);
507-
McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals);
508541
this.connection.close();
509542
}
510543

mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,71 @@ void closingTheSessionReleasesTheConnectionOfItsStreams() {
7474
assertThat(transport.closed).isTrue();
7575
}
7676

77+
@Test
78+
void closingTheSessionFailsThePendingRequestsOfReleasedStreams() {
79+
var session = session();
80+
session.listeningStream(new RecordingTransport());
81+
82+
var pending = session.sendRequest("sampling/createMessage", null, new TypeRef<String>() {
83+
}).toFuture();
84+
85+
// The stream the request was sent on is released, which leaves the request
86+
// pending: a reconnecting client can still answer it over a separate POST
87+
session.releaseListeningStream();
88+
assertThat(pending).isNotDone();
89+
90+
// The client cannot respond on a closed session, the pending request must be
91+
// failed.
92+
session.close();
93+
94+
assertThat(pending).failsWithin(TIMEOUT).withThrowableThat().havingCause().withMessage("Session closed");
95+
}
96+
97+
@Test
98+
void closingTheSessionGracefullyFailsThePendingRequestsOfReleasedStreams() {
99+
var session = session();
100+
session.listeningStream(new RecordingTransport());
101+
102+
var pending = session.sendRequest("sampling/createMessage", null, new TypeRef<String>() {
103+
}).toFuture();
104+
105+
session.releaseListeningStream();
106+
assertThat(pending).isNotDone();
107+
108+
session.closeGracefully().block(TIMEOUT);
109+
110+
assertThat(pending).failsWithin(TIMEOUT).withThrowableThat().havingCause().withMessage("Session closed");
111+
}
112+
113+
@Test
114+
void closingAStreamFailsOnlyItsOwnPendingRequests() {
115+
var session = session();
116+
var listeningTransport = new RecordingTransport();
117+
session.listeningStream(listeningTransport);
118+
119+
var onListeningStream = session.sendRequest("sampling/createMessage", null, new TypeRef<String>() {
120+
}).toFuture();
121+
122+
// A POST response stream carries its own server-initiated requests, which can be
123+
// closed independently
124+
var responseTransport = new RecordingTransport();
125+
var responseStream = session.new McpStreamableServerSessionStream(responseTransport);
126+
var onResponseStream = responseStream.sendRequest("elicitation/create", null, new TypeRef<String>() {
127+
}).toFuture();
128+
129+
responseStream.close();
130+
131+
assertThat(onResponseStream).failsWithin(TIMEOUT)
132+
.withThrowableThat()
133+
.havingCause()
134+
.withMessage("Stream closed");
135+
assertThat(onListeningStream).isNotDone();
136+
137+
var requestId = ((McpSchema.JSONRPCRequest) listeningTransport.sent.peek()).id();
138+
session.accept(McpSchema.JSONRPCResponse.result(requestId, "response-value")).block(TIMEOUT);
139+
assertThat(onListeningStream).succeedsWithin(TIMEOUT).isEqualTo("response-value");
140+
}
141+
77142
static class RecordingTransport implements McpStreamableServerTransport {
78143

79144
final Queue<McpSchema.JSONRPCMessage> sent = new ConcurrentLinkedQueue<>();

0 commit comments

Comments
 (0)