This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new 903363f9bf Isolate pending requests in MCP Streamable HTTP sessions
(#7638)
903363f9bf is described below
commit 903363f9bf3c7aa92d22acd97a0c66fe208bb7d5
Author: BobSong <[email protected]>
AuthorDate: Sat Oct 17 20:47:41 2026 +0800
Isolate pending requests in MCP Streamable HTTP sessions (#7638)
* fix(mcp): isolate pending Streamable HTTP request lifecycles
* style(mcp): group Streamable HTTP transport test imports
---------
Co-authored-by: BobSong-dev <[email protected]>
---
...henyuStreamableHttpServerTransportProvider.java | 151 +++++++---
...uStreamableHttpServerTransportProviderTest.java | 331 +++++++++++++++++++--
2 files changed, 420 insertions(+), 62 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java
b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java
index ba356c2a14..b0bd801dbd 100644
---
a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java
+++
b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProvider.java
@@ -40,6 +40,7 @@ import
org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
import java.io.IOException;
import java.util.LinkedHashSet;
@@ -74,6 +75,8 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
*/
private static final String JSONRPC_VERSION = "2.0";
+ private static final Object REQUEST_RESPONSE_CONTEXT_KEY = new Object();
+
/**
* MCP initialize method name.
*/
@@ -551,18 +554,27 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
// Let MCP framework handle the message - framework will send response
through transport
final McpTransportContext transportContext =
McpTransportContext.create(
Map.of(McpSessionHelper.SHENYU_EXCHANGE_CONTEXT_KEY,
exchange));
- return Mono.defer(() -> session.handle(message))
- // The exchange belongs to this JSON-RPC request, not to the
session.
- .contextWrite(context -> context.put(McpTransportContext.KEY,
transportContext))
- .cast(Object.class)
- .doOnSuccess(result -> LOGGER.debug("Successfully processed
message for session: {}", sessionId))
- .then(waitForTransportResponse(transport, sessionId,
messageId))
- .doFinally(signal -> {
- // Release only this request's response on completion,
error or cancellation.
- if (Objects.nonNull(transport)) {
- transport.resetCapturedMessage(messageId);
- }
- })
+ return Mono.defer(() -> {
+ if (Objects.isNull(transport)) {
+ return Mono.error(new IllegalStateException("MCP session is
closed"));
+ }
+ final boolean requestMessage = message instanceof
McpSchema.JSONRPCRequest;
+ final PendingResponse pending = requestMessage &&
Objects.nonNull(messageId) ? transport.registerRequest(messageId) : null;
+ if (requestMessage && Objects.isNull(pending)) {
+ return
Mono.just(createMessageHandlingResult(HttpStatus.BAD_REQUEST.value(),
+ createJsonRpcError(messageId, -32600, "Invalid or
duplicate pending request id"), sessionId));
+ }
+ Mono<MessageHandlingResult> result = Mono.defer(() ->
session.handle(message))
+ .contextWrite(context -> Objects.isNull(pending) ?
context.put(McpTransportContext.KEY, transportContext)
+ : context.put(McpTransportContext.KEY,
transportContext).put(REQUEST_RESPONSE_CONTEXT_KEY, pending))
+ .then(waitForTransportResponse(transport, sessionId,
messageId, pending));
+ if (Objects.nonNull(pending)) {
+ final Mono<MessageHandlingResult> closed =
pending.closed.asMono().then(Mono.error(new IllegalStateException("MCP session
is closed")));
+ result = Mono.firstWithSignal(closed, result)
+ .doFinally(signal -> transport.finishRequest(pending));
+ }
+ return result;
+ })
.onErrorResume(error -> {
LOGGER.error("Error processing message for session {}:
{}", sessionId, error.getMessage(), error);
final Object errorResponse = createJsonRpcError(messageId,
-32603,
@@ -809,8 +821,7 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
final McpServerSession removedSession = sessions.remove(sessionId);
final StreamableHttpSessionTransport removedTransport =
sessionTransports.remove(sessionId);
if (Objects.nonNull(removedTransport)) {
- removedTransport.closed = true;
- removedTransport.resetCapturedMessage();
+ removedTransport.closeRequests();
}
ShenyuMcpExchangeHolder.remove(sessionId);
if (Objects.nonNull(removedSession) ||
Objects.nonNull(removedTransport)) {
@@ -836,14 +847,17 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
* @param transport the session transport to check for responses
* @param sessionId the session identifier for logging
* @param messageId the original message ID for correlation
+ * @param pending the response owned by this request, or null for
non-request messages
* @return a Mono containing the message handling result
*/
private Mono<MessageHandlingResult> waitForTransportResponse(final
StreamableHttpSessionTransport transport,
final String
sessionId,
- final Object
messageId) {
+ final Object
messageId,
+ final
PendingResponse pending) {
return Mono.fromCallable(() -> {
- if (Objects.nonNull(transport) &&
transport.isResponseReady(messageId)) {
- final McpSchema.JSONRPCMessage sentMessage =
transport.getResponse(messageId);
+ final McpSchema.JSONRPCMessage sentMessage =
Objects.nonNull(pending)
+ ? transport.getRequestResponse(pending) :
transport.getResponse(messageId);
+ if (Objects.nonNull(sentMessage)) {
LOGGER.debug("Retrieved captured response from transport for
session: {}", sessionId);
return createMessageHandlingResult(200, sentMessage,
sessionId);
} else {
@@ -1047,12 +1061,21 @@ public class
ShenyuStreamableHttpServerTransportProvider implements McpServerTra
}
}
- /**
- * Session transport implementation for Streamable HTTP with proper
lifecycle management.
- * This transport handles the communication between the MCP framework and
the Streamable HTTP
- * protocol. It captures responses from the MCP session and makes them
available for
- * HTTP response correlation.
- */
+ /** Response and close signal belonging to one subscribed HTTP request,
never just to an RPC id. */
+ private static final class PendingResponse {
+
+ private final Object messageId;
+
+ private final Sinks.Empty<Void> closed = Sinks.empty();
+
+ private McpSchema.JSONRPCMessage response;
+
+ private PendingResponse(final Object messageId) {
+ this.messageId = messageId;
+ }
+ }
+
+ /** Session transport with request-owned response capture and
cancellation. */
private class StreamableHttpSessionTransport implements McpServerTransport
{
private String sessionId;
@@ -1063,6 +1086,8 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
private final ConcurrentHashMap<Object, McpSchema.JSONRPCMessage>
responses = new ConcurrentHashMap<>();
+ private final Map<Object, PendingResponse> pendingResponses = new
ConcurrentHashMap<>();
+
private volatile boolean responseReady;
/**
@@ -1138,17 +1163,65 @@ public class
ShenyuStreamableHttpServerTransportProvider implements McpServerTra
return responses.get(messageId);
}
+ private synchronized PendingResponse registerRequest(final Object
messageId) {
+ if (closed) {
+ throw new IllegalStateException("MCP session is closed");
+ }
+ if (pendingResponses.containsKey(messageId)) {
+ return null;
+ }
+ final PendingResponse pending = new PendingResponse(messageId);
+ pendingResponses.put(messageId, pending);
+ return pending;
+ }
+
+ private synchronized McpSchema.JSONRPCMessage getRequestResponse(final
PendingResponse pending) {
+ if (closed || pendingResponses.get(pending.messageId) != pending) {
+ throw new IllegalStateException("MCP request is no longer
active");
+ }
+ return pending.response;
+ }
+
+ private synchronized void finishRequest(final PendingResponse pending)
{
+ pendingResponses.remove(pending.messageId, pending);
+ pending.response = null;
+ }
+
+ private void closeRequests() {
+ final List<PendingResponse> pending;
+ synchronized (this) {
+ closed = true;
+ pending = List.copyOf(pendingResponses.values());
+ pendingResponses.clear();
+ pending.forEach(response -> response.response = null);
+ resetCapturedMessage();
+ }
+ // Signal outside the lock: cancellation can execute arbitrary
handler cleanup.
+ pending.forEach(response -> response.closed.tryEmitEmpty());
+ }
+
@Override
public Mono<Void> sendMessage(final McpSchema.JSONRPCMessage message) {
- if (!closed) {
- this.lastSentMessage = message;
- if (message instanceof McpSchema.JSONRPCResponse response &&
Objects.nonNull(response.id())) {
- responses.put(response.id(), message);
+ return Mono.deferContextual(context -> {
+ synchronized (this) {
+ if (closed) {
+ return Mono.empty();
+ }
+ final PendingResponse pending =
context.getOrDefault(REQUEST_RESPONSE_CONTEXT_KEY, null);
+ if (Objects.nonNull(pending)) {
+ if (pendingResponses.get(pending.messageId) == pending
&& message instanceof McpSchema.JSONRPCResponse response
+ && Objects.equals(pending.messageId,
response.id())) {
+ pending.response = message;
+ }
+ } else if (message instanceof McpSchema.JSONRPCResponse
response && "__backend_init".equals(response.id())) {
+ // Only the internal handshake uses the session-level
compatibility slots.
+ this.lastSentMessage = message;
+ responses.put(response.id(), message);
+ this.responseReady = true;
+ }
}
- this.responseReady = true;
- LOGGER.debug("Captured response message for session: {}",
sessionId);
- }
- return Mono.empty();
+ return Mono.empty();
+ });
}
@Override
@@ -1158,22 +1231,12 @@ public class
ShenyuStreamableHttpServerTransportProvider implements McpServerTra
@Override
public Mono<Void> closeGracefully() {
- return Mono.fromRunnable(() -> {
- if (!closed) {
- closed = true;
- removeSession(sessionId);
- LOGGER.debug("Session transport closed gracefully: {}",
sessionId);
- }
- });
+ return Mono.fromRunnable(() -> removeSession(sessionId));
}
@Override
public void close() {
- if (!closed) {
- closed = true;
- removeSession(sessionId);
- LOGGER.debug("Session transport closed immediately: {}",
sessionId);
- }
+ removeSession(sessionId);
}
/**
@@ -1181,7 +1244,7 @@ public class ShenyuStreamableHttpServerTransportProvider
implements McpServerTra
* business request is not confused with the internal initialize
handshake that
* is executed during short-reconnect or temporary session creation.
*/
- public void resetCapturedMessage() {
+ public synchronized void resetCapturedMessage() {
this.responses.clear();
this.lastSentMessage = null;
this.responseReady = false;
diff --git
a/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
index e73ccae0dc..0ac453ac0a 100644
---
a/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
+++
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/transport/ShenyuStreamableHttpServerTransportProviderTest.java
@@ -17,40 +17,56 @@
package org.apache.shenyu.plugin.mcp.server.transport;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.server.McpRequestHandler;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransport;
-import io.modelcontextprotocol.server.McpRequestHandler;
-import org.apache.shenyu.plugin.mcp.server.session.McpSessionHelper;
-import org.springframework.web.server.ServerWebExchange;
-import reactor.core.publisher.Flux;
-import reactor.core.Disposable;
-import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.shenyu.plugin.mcp.server.holder.ShenyuMcpExchangeHolder;
+import org.apache.shenyu.plugin.mcp.server.session.McpSessionHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.HttpMessageWriter;
+import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.HandlerStrategies;
+import org.springframework.web.reactive.function.server.RequestPredicates;
+import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.netty.http.server.HttpServer;
+import reactor.netty.resources.LoopResources;
import reactor.test.StepVerifier;
-
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import java.time.Duration;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
-import java.util.UUID;
+import reactor.util.context.Context;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -393,10 +409,288 @@ class ShenyuStreamableHttpServerTransportProviderTest {
}
}
+ @Test
+ void testCompletedRequestReleasesLastResponse() throws Exception {
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithRealSessions();
+ final String sessionId = initialize(provider);
+ try {
+ performRequest(provider, postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId));
+ final Object transport = readMap(provider,
"sessionTransports").get(sessionId);
+ final Method lookup =
transport.getClass().getDeclaredMethod("getLastSentMessage");
+ lookup.setAccessible(true);
+ assertNull(lookup.invoke(transport), "A completed request must not
retain its result in the session slot");
+ } finally {
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testDuplicatePendingIdDoesNotInvokeSecondHandler() {
+ final AtomicInteger calls = new AtomicInteger();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) ->
+ calls.incrementAndGet() == 1 ? Mono.never() :
Mono.just(Map.of("marker", "second")));
+ final String sessionId = initialize(provider);
+ final Disposable pending =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId))).subscribe();
+ try {
+ assertEquals(1, calls.get());
+ final MockServerHttpResponse duplicate = performRequest(provider,
postRequest(TOOLS_LIST_REQUEST_BODY, sessionId));
+ assertEquals(HttpStatus.BAD_REQUEST, duplicate.getStatusCode());
+ assertEquals(1, calls.get(), "Duplicate in-flight IDs must be
rejected before a second handler executes");
+ } finally {
+ pending.dispose();
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testSessionRemovalCancelsPendingHandler() {
+ final AtomicBoolean cancelled = new AtomicBoolean();
+ final AtomicReference<ServerResponse> result = new AtomicReference<>();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) ->
+ Mono.<Map<String, Object>>never().doOnCancel(() ->
cancelled.set(true)));
+ final String sessionId = initialize(provider);
+ final Disposable pending =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId))).subscribe(result::set);
+ try {
+ provider.removeSession(sessionId);
+ assertTrue(cancelled.get(), "Closing a session must cancel its
owned handler subscription");
+ assertNotNull(result.get(), "A closed session must terminate the
waiting HTTP request");
+ assertEquals(HttpStatus.INTERNAL_SERVER_ERROR,
result.get().statusCode());
+ } finally {
+ pending.dispose();
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testLateResponseAfterCancellationIsDiscarded() throws Exception {
+ final AtomicReference<Context> requestContext = new
AtomicReference<>();
+ final AtomicReference<McpServerTransport> transport = new
AtomicReference<>();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) ->
+ Mono.deferContextual(context -> {
+ requestContext.set(Context.of(context));
+ return Mono.never();
+ }), transport);
+ final String sessionId = initialize(provider);
+ final Disposable pending =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId))).subscribe();
+ try {
+ assertNotNull(requestContext.get());
+ pending.dispose();
+ final McpSchema.JSONRPCResponse late = new
McpSchema.JSONRPCResponse("2.0", "req-1", Map.of("private", "late-result"),
null);
+
transport.get().sendMessage(late).contextWrite(requestContext.get()).block(Duration.ofSeconds(3));
+ final Field responses =
transport.get().getClass().getDeclaredField("responses");
+ responses.setAccessible(true);
+ assertTrue(((Map<?, ?>) responses.get(transport.get())).isEmpty(),
"Late responses must not repopulate cancelled request state");
+ } finally {
+ pending.dispose();
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testCancelledOwnerCannotOverwriteSameIdSuccessor() {
+ final AtomicReference<Context> oldContext = new AtomicReference<>();
+ final AtomicReference<McpServerTransport> transport = new
AtomicReference<>();
+ final AtomicInteger calls = new AtomicInteger();
+ final Sinks.One<Map<String, Object>> freshResult = Sinks.one();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) -> Mono.deferContextual(context -> {
+ if (calls.incrementAndGet() == 1) {
+ oldContext.set(Context.of(context));
+ return Mono.never();
+ }
+ return freshResult.asMono();
+ }), transport);
+ final String sessionId = initialize(provider);
+ final Disposable first =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId))).subscribe();
+ first.dispose();
+ final AtomicReference<ServerResponse> result = new AtomicReference<>();
+ final Disposable second =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId))).subscribe(result::set);
+ try {
+ assertEquals(2, calls.get());
+ transport.get().sendMessage(new McpSchema.JSONRPCResponse("2.0",
"req-1", Map.of("marker", "old-private"), null))
+
.contextWrite(oldContext.get()).block(Duration.ofSeconds(3));
+ assertNull(result.get());
+ freshResult.emitValue(Map.of("marker", "fresh"),
Sinks.EmitFailureHandler.FAIL_FAST);
+ assertNotNull(result.get());
+ final MockServerWebExchange output =
MockServerWebExchange.from(MockServerHttpRequest.get("/result"));
+ result.get().writeTo(output,
RESPONSE_CONTEXT).block(Duration.ofSeconds(3));
+ final String body =
output.getResponse().getBodyAsString().block(Duration.ofSeconds(3));
+ assertTrue(body.contains("\"marker\":\"fresh\""), body);
+ assertTrue(!body.contains("old-private"), body);
+ } finally {
+ first.dispose();
+ second.dispose();
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testSameIdInDifferentSessionsKeepsPrivateResults() {
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) -> {
+ final ServerWebExchange request = (ServerWebExchange)
exchange.transportContext().get(McpSessionHelper.SHENYU_EXCHANGE_CONTEXT_KEY);
+ return
Mono.delay(Duration.ofMillis(10)).thenReturn(Map.of("marker",
request.getRequest().getHeaders().getFirst("X-Test-Request")));
+ });
+ final List<String> sessions = List.of(initialize(provider),
initialize(provider));
+ try {
+ StepVerifier.create(Flux.range(0, 2).flatMap(index -> {
+ final String marker = "private-" + index;
+ final MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.post("/mcp/streamablehttp")
+ .header("Content-Type",
"application/json").header(SESSION_ID_HEADER, sessions.get(index))
+ .header("X-Test-Request",
marker).body(TOOLS_LIST_REQUEST_BODY));
+ return
provider.handleUnifiedEndpoint(ServerRequest.create(exchange,
HandlerStrategies.withDefaults().messageReaders()))
+ .flatMap(response -> response.writeTo(exchange,
RESPONSE_CONTEXT))
+ .then(Mono.defer(() ->
exchange.getResponse().getBodyAsString()))
+ .doOnNext(body ->
assertTrue(body.contains("\"marker\":\"" + marker + "\""), body));
+ })).expectNextCount(2).verifyComplete();
+ } finally {
+ sessions.forEach(provider::removeSession);
+ }
+ }
+
+ @Test
+ void testClosingOneSessionLeavesAnotherRequestActive() {
+ final AtomicBoolean firstCancelled = new AtomicBoolean();
+ final AtomicBoolean secondCancelled = new AtomicBoolean();
+ final Sinks.One<Map<String, Object>> secondResult = Sinks.one();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) -> {
+ final ServerWebExchange request = (ServerWebExchange)
exchange.transportContext().get(McpSessionHelper.SHENYU_EXCHANGE_CONTEXT_KEY);
+ return
"close".equals(request.getRequest().getHeaders().getFirst("X-Test-Request"))
+ ? Mono.<Map<String, Object>>never().doOnCancel(() ->
firstCancelled.set(true))
+ : secondResult.asMono().doOnCancel(() ->
secondCancelled.set(true));
+ });
+ final String firstSession = initialize(provider);
+ final String secondSession = initialize(provider);
+ final Disposable first =
provider.handleUnifiedEndpoint(createRequest(MockServerHttpRequest.post("/mcp/streamablehttp")
+ .header("Content-Type",
"application/json").header(SESSION_ID_HEADER, firstSession)
+ .header("X-Test-Request",
"close").body(TOOLS_LIST_REQUEST_BODY))).subscribe();
+ final AtomicReference<ServerResponse> result = new AtomicReference<>();
+ final Disposable second =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
secondSession))).subscribe(result::set);
+ try {
+ provider.removeSession(firstSession);
+ assertTrue(firstCancelled.get());
+ assertTrue(!secondCancelled.get());
+ assertNull(result.get());
+ secondResult.emitValue(Map.of("tools", List.of()),
Sinks.EmitFailureHandler.FAIL_FAST);
+ assertNotNull(result.get());
+ assertEquals(HttpStatus.OK, result.get().statusCode());
+ } finally {
+ first.dispose();
+ second.dispose();
+ provider.removeSession(firstSession);
+ provider.removeSession(secondSession);
+ }
+ }
+
+ @Test
+ void testCallerDeadlineReleasesPendingRequest() throws Exception {
+ final AtomicBoolean cancelled = new AtomicBoolean();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) ->
+ Mono.<Map<String, Object>>never().doOnCancel(() ->
cancelled.set(true)));
+ final String sessionId = initialize(provider);
+ try {
+ StepVerifier.withVirtualTime(() ->
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId)))
+ .timeout(Duration.ofSeconds(1)))
+
.thenAwait(Duration.ofSeconds(1)).expectError(TimeoutException.class).verify(Duration.ofSeconds(3));
+ assertTrue(cancelled.get());
+ final Object transport = readMap(provider,
"sessionTransports").get(sessionId);
+ final Field pending =
transport.getClass().getDeclaredField("pendingResponses");
+ pending.setAccessible(true);
+ assertTrue(((Map<?, ?>) pending.get(transport)).isEmpty());
+ } finally {
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testGracefulShutdownCancelsPendingRequest() throws Exception {
+ final AtomicBoolean cancelled = new AtomicBoolean();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) ->
+ Mono.<Map<String, Object>>never().doOnCancel(() ->
cancelled.set(true)));
+ final String sessionId = initialize(provider);
+ final Disposable pending =
provider.handleUnifiedEndpoint(createRequest(postRequest(TOOLS_LIST_REQUEST_BODY,
sessionId))).subscribe();
+ try {
+ StepVerifier.create(provider.closeGracefully()).verifyComplete();
+ assertTrue(cancelled.get());
+ assertTrue(readMap(provider, "sessions").isEmpty());
+ assertTrue(readMap(provider, "sessionTransports").isEmpty());
+ assertNull(ShenyuMcpExchangeHolder.get(sessionId));
+ } finally {
+ pending.dispose();
+ provider.removeSession(sessionId);
+ }
+ }
+
+ @Test
+ void testRealHttpDuplicateAndDisconnectKeepPeerHealthy() throws Exception {
+ final CountDownLatch started = new CountDownLatch(1);
+ final CountDownLatch cancelled = new CountDownLatch(1);
+ final AtomicInteger calls = new AtomicInteger();
+ final ShenyuStreamableHttpServerTransportProvider provider =
providerWithHandler((exchange, params) -> {
+ calls.incrementAndGet();
+ final ServerWebExchange request = (ServerWebExchange)
exchange.transportContext().get(McpSessionHelper.SHENYU_EXCHANGE_CONTEXT_KEY);
+ final String marker =
request.getRequest().getHeaders().getFirst("X-Test-Request");
+ return "pending".equals(marker) ? Mono.<Map<String,
Object>>never().doOnSubscribe(subscription -> started.countDown())
+ .doOnCancel(cancelled::countDown) :
Mono.just(Map.of("marker", marker));
+ });
+ final LoopResources loops = LoopResources.create("mcp-lifecycle-test",
2, true);
+ final ReactorHttpHandlerAdapter adapter = new
ReactorHttpHandlerAdapter(RouterFunctions.toHttpHandler(
+
RouterFunctions.route(RequestPredicates.POST("/mcp/streamablehttp"),
provider::handleUnifiedEndpoint)));
+ final reactor.netty.DisposableServer server =
HttpServer.create().host("127.0.0.1").port(0).runOn(loops).handle(adapter).bindNow();
+ final WebClient client = WebClient.create("http://127.0.0.1:" +
server.port());
+ CompletableFuture<ResponseEntity<String>> pending = null;
+ String sessionId = null;
+ try {
+ final ResponseEntity<String> initialized = networkPost(client,
INITIALIZE_REQUEST_BODY, "", "init").block(Duration.ofSeconds(10));
+ assertNotNull(initialized);
+ sessionId = initialized.getHeaders().getFirst(SESSION_ID_HEADER);
+ assertNotNull(sessionId);
+ assertEquals(HttpStatus.ACCEPTED, networkPost(client,
INITIALIZED_NOTIFICATION_BODY, sessionId, "init")
+ .block(Duration.ofSeconds(10)).getStatusCode());
+ pending = networkPost(client, TOOLS_LIST_REQUEST_BODY, sessionId,
"pending").toFuture();
+ assertTrue(started.await(10, TimeUnit.SECONDS));
+ final ResponseEntity<String> duplicate = networkPost(client,
TOOLS_LIST_REQUEST_BODY, sessionId, "duplicate").block(Duration.ofSeconds(10));
+ assertNotNull(duplicate);
+ assertEquals(HttpStatus.BAD_REQUEST, duplicate.getStatusCode());
+ assertEquals(1, calls.get());
+ final ResponseEntity<String> peer = networkPost(client,
TOOLS_LIST_REQUEST_BODY.replace("req-1", "req-2"), sessionId, "peer")
+ .block(Duration.ofSeconds(10));
+ assertNotNull(peer);
+ assertTrue(peer.getBody().contains("\"marker\":\"peer\""),
peer.getBody());
+ pending.cancel(true);
+ assertTrue(cancelled.await(10, TimeUnit.SECONDS), "Real HTTP
disconnect must cancel the owned SDK handler");
+ final ResponseEntity<String> next = networkPost(client,
TOOLS_LIST_REQUEST_BODY.replace("req-1", "req-3"), sessionId, "next")
+ .block(Duration.ofSeconds(10));
+ assertNotNull(next);
+ assertTrue(next.getBody().contains("\"marker\":\"next\""),
next.getBody());
+ assertEquals(3, calls.get());
+ } finally {
+ if (Objects.nonNull(pending)) {
+ pending.cancel(true);
+ }
+ if (Objects.nonNull(sessionId)) {
+ provider.removeSession(sessionId);
+ }
+ server.disposeNow();
+ loops.disposeLater().block(Duration.ofSeconds(10));
+ }
+ }
+
+ private Mono<ResponseEntity<String>> networkPost(final WebClient client,
final String body, final String sessionId, final String marker) {
+ return
client.post().uri("/mcp/streamablehttp").contentType(MediaType.APPLICATION_JSON)
+ .header(SESSION_ID_HEADER, sessionId).header("X-Test-Request",
marker).bodyValue(body)
+ .exchangeToMono(response -> response.toEntity(String.class));
+ }
+
private ShenyuStreamableHttpServerTransportProvider
providerWithHandler(final McpRequestHandler<Map<String, Object>> handler) {
+ return providerWithHandler(handler, new AtomicReference<>());
+ }
+
+ private ShenyuStreamableHttpServerTransportProvider
providerWithHandler(final McpRequestHandler<Map<String, Object>> handler,
+
final AtomicReference<McpServerTransport> capturedTransport) {
ShenyuStreamableHttpServerTransportProvider provider =
new ShenyuStreamableHttpServerTransportProvider(new
ObjectMapper(), "/mcp/streamablehttp");
- provider.setSessionFactory(transport -> new McpServerSession(
+ provider.setSessionFactory(transport -> {
+ capturedTransport.set(transport);
+ return new McpServerSession(
UUID.randomUUID().toString(),
Duration.ofSeconds(10),
transport,
@@ -406,7 +700,8 @@ class ShenyuStreamableHttpServerTransportProviderTest {
new McpSchema.Implementation("ShenyuMcpServer",
"1.0.0"),
"test")),
Map.<String, McpRequestHandler<?>>of("tools/list", handler),
- Map.of()));
+ Map.of());
+ });
return provider;
}