Fix Streamable HTTP WebClient GET SSE handling

Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
Dariusz Jędrzejczyk 2025-06-16 11:21:26 +02:00 • committed by Christian Tzolov
commit 8e9857f181
4 changed files with 75 additions and 14 deletions

View file

@ -125,13 +125,14 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
} }
private DefaultMcpTransportSession createTransportSession() { private DefaultMcpTransportSession createTransportSession() {
Supplier<Publisher<Void>> onClose = () -> { Function<String, Publisher<Void>> onClose = sessionId -> sessionId == null ? Mono.empty()
DefaultMcpTransportSession transportSession = this.activeSession.get(); : webClient.delete().uri(this.endpoint).headers(httpHeaders -> {
return transportSession.sessionId().isEmpty() ? Mono.empty() httpHeaders.add("mcp-session-id", sessionId);
: webClient.delete().uri(this.endpoint).headers(httpHeaders -> { })
httpHeaders.add("mcp-session-id", transportSession.sessionId().get()); .retrieve()
}).retrieve().toBodilessEntity().doOnError(e -> logger.info("Got response {}", e)).then(); .toBodilessEntity()
}; .doOnError(e -> logger.warn("Got error when closing transport", e))
.then();
return new DefaultMcpTransportSession(onClose); return new DefaultMcpTransportSession(onClose);
} }
@ -192,6 +193,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
}) })
.exchangeToFlux(response -> { .exchangeToFlux(response -> {
if (isEventStream(response)) { if (isEventStream(response)) {
logger.debug("Established SSE stream via GET");
return eventStream(stream, response); return eventStream(stream, response);
} }
else if (isNotAllowed(response)) { else if (isNotAllowed(response)) {
@ -208,6 +210,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
}).flux(); }).flux();
} }
}) })
.flatMap(jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
.onErrorComplete(t -> { .onErrorComplete(t -> {
this.handleException(t); this.handleException(t);
return true; return true;
@ -274,6 +277,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
else { else {
MediaType mediaType = contentType.get(); MediaType mediaType = contentType.get();
if (mediaType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM)) { if (mediaType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM)) {
logger.debug("Established SSE stream via POST");
// communicate to caller that the message was delivered // communicate to caller that the message was delivered
sink.success(); sink.success();
// starting a stream // starting a stream

View file

@ -19,6 +19,8 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
import io.modelcontextprotocol.spec.McpSchema.Prompt; import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.Resource; import io.modelcontextprotocol.spec.McpSchema.Resource;
@ -77,7 +79,9 @@ public abstract class AbstractMcpAsyncClientTests {
McpClient.AsyncSpec builder = McpClient.async(transport) McpClient.AsyncSpec builder = McpClient.async(transport)
.requestTimeout(getRequestTimeout()) .requestTimeout(getRequestTimeout())
.initializationTimeout(getInitializationTimeout()) .initializationTimeout(getInitializationTimeout())
.capabilities(ClientCapabilities.builder().roots(true).build()); .sampling(req -> Mono.just(new CreateMessageResult(McpSchema.Role.USER,
new McpSchema.TextContent("Oh, hi!"), "modelId", CreateMessageResult.StopReason.END_TURN)))
.capabilities(ClientCapabilities.builder().roots(true).sampling().build());
builder = customizer.apply(builder); builder = customizer.apply(builder);
client.set(builder.build()); client.set(builder.build());
}).doesNotThrowAnyException(); }).doesNotThrowAnyException();
@ -189,6 +193,22 @@ public abstract class AbstractMcpAsyncClientTests {
}); });
} }
@Test
void testSampling() {
withClient(createMcpTransport(), mcpAsyncClient -> {
CallToolRequest callToolRequest = new CallToolRequest("sampleLLM",
Map.of("prompt", "Hello MCP Spring AI!"));
StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest)))
.consumeNextWith(callToolResult -> {
assertThat(callToolResult).isNotNull().satisfies(result -> {
assertThat(result.content()).isNotNull();
assertThat(result.isError()).isNull();
});
})
.verifyComplete();
});
}
@Test @Test
void testCallToolWithInvalidTool() { void testCallToolWithInvalidTool() {
withClient(createMcpTransport(), mcpAsyncClient -> { withClient(createMcpTransport(), mcpAsyncClient -> {
@ -424,6 +444,20 @@ public abstract class AbstractMcpAsyncClientTests {
}); });
} }
@Test
void testInitializeWithElicitationCapability() {
ClientCapabilities capabilities = ClientCapabilities.builder().elicitation().build();
ElicitResult elicitResult = ElicitResult.builder()
.message(ElicitResult.Action.ACCEPT)
.content(Map.of("foo", "bar"))
.build();
withClient(createMcpTransport(),
builder -> builder.capabilities(capabilities).elicitation(request -> Mono.just(elicitResult)),
client -> {
StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete();
});
}
@Test @Test
void testInitializeWithAllCapabilities() { void testInitializeWithAllCapabilities() {
var capabilities = ClientCapabilities.builder() var capabilities = ClientCapabilities.builder()
@ -435,7 +469,11 @@ public abstract class AbstractMcpAsyncClientTests {
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
.just(CreateMessageResult.builder().message("test").model("test-model").build()); .just(CreateMessageResult.builder().message("test").model("test-model").build());
withClient(createMcpTransport(), builder -> builder.capabilities(capabilities).sampling(samplingHandler), Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler = request -> Mono
.just(ElicitResult.builder().message(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build());
withClient(createMcpTransport(),
builder -> builder.capabilities(capabilities).sampling(samplingHandler).elicitation(elicitationHandler),
client -> client ->
StepVerifier.create(client.initialize()).assertNext(result -> { StepVerifier.create(client.initialize()).assertNext(result -> {

View file

@ -10,7 +10,7 @@ import reactor.core.publisher.Mono;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier; import java.util.function.Function;
/** /**
* Default implementation of {@link McpTransportSession} which manages the open * Default implementation of {@link McpTransportSession} which manages the open
@ -29,9 +29,9 @@ public class DefaultMcpTransportSession implements McpTransportSession<Disposabl
private final AtomicReference<String> sessionId = new AtomicReference<>(); private final AtomicReference<String> sessionId = new AtomicReference<>();
private final Supplier<Publisher<Void>> onClose; private final Function<String, Publisher<Void>> onClose;
public DefaultMcpTransportSession(Supplier<Publisher<Void>> onClose) { public DefaultMcpTransportSession(Function<String, Publisher<Void>> onClose) {
this.onClose = onClose; this.onClose = onClose;
} }
@ -73,7 +73,8 @@ public class DefaultMcpTransportSession implements McpTransportSession<Disposabl
@Override @Override
public Mono<Void> closeGracefully() { public Mono<Void> closeGracefully() {
return Mono.from(this.onClose.get()).then(Mono.fromRunnable(this.openConnections::dispose)); return Mono.from(this.onClose.apply(this.sessionId.get()))
.then(Mono.fromRunnable(this.openConnections::dispose));
} }
} }

View file

@ -80,7 +80,9 @@ public abstract class AbstractMcpAsyncClientTests {
McpClient.AsyncSpec builder = McpClient.async(transport) McpClient.AsyncSpec builder = McpClient.async(transport)
.requestTimeout(getRequestTimeout()) .requestTimeout(getRequestTimeout())
.initializationTimeout(getInitializationTimeout()) .initializationTimeout(getInitializationTimeout())
.capabilities(ClientCapabilities.builder().roots(true).build()); .sampling(req -> Mono.just(new CreateMessageResult(McpSchema.Role.USER,
new McpSchema.TextContent("Oh, hi!"), "modelId", CreateMessageResult.StopReason.END_TURN)))
.capabilities(ClientCapabilities.builder().roots(true).sampling().build());
builder = customizer.apply(builder); builder = customizer.apply(builder);
client.set(builder.build()); client.set(builder.build());
}).doesNotThrowAnyException(); }).doesNotThrowAnyException();
@ -192,6 +194,22 @@ public abstract class AbstractMcpAsyncClientTests {
}); });
} }
@Test
void testSampling() {
withClient(createMcpTransport(), mcpAsyncClient -> {
CallToolRequest callToolRequest = new CallToolRequest("sampleLLM",
Map.of("prompt", "Hello MCP Spring AI!"));
StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest)))
.consumeNextWith(callToolResult -> {
assertThat(callToolResult).isNotNull().satisfies(result -> {
assertThat(result.content()).isNotNull();
assertThat(result.isError()).isNull();
});
})
.verifyComplete();
});
}
@Test @Test
void testCallToolWithInvalidTool() { void testCallToolWithInvalidTool() {
withClient(createMcpTransport(), mcpAsyncClient -> { withClient(createMcpTransport(), mcpAsyncClient -> {