Introduce HttpRequest.Builder customizer for HttpClient-based transport
- Minor improvement: speed up HttpClientSseClientTransportTests by reusing the MCP Server container across tests. - Minor improvement: rename "messageSink" to "deliveredSink" in HttpClientStreamableHttpTransport#sendMessage
This commit is contained in:
parent
bde1b6beec
commit
ae2fc8683b
6 changed files with 539 additions and 150 deletions
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024-2025 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.client.transport;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import org.reactivestreams.Publisher;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import reactor.util.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize {@link HttpRequest.Builder} before executing the request, in either SSE or
|
||||||
|
* Streamable HTTP transport.
|
||||||
|
* <p>
|
||||||
|
* When used in a non-blocking context, implementations MUST be non-blocking.
|
||||||
|
*
|
||||||
|
* @author Daniel Garnier-Moiroux
|
||||||
|
*/
|
||||||
|
public interface AsyncHttpRequestCustomizer {
|
||||||
|
|
||||||
|
Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
|
||||||
|
@Nullable String body);
|
||||||
|
|
||||||
|
AsyncHttpRequestCustomizer NOOP = new Noop();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a sync implementation in an async wrapper.
|
||||||
|
* <p>
|
||||||
|
* Do NOT wrap a blocking implementation for use in a non-blocking context. For a
|
||||||
|
* blocking implementation, consider using {@link Schedulers#boundedElastic()}.
|
||||||
|
*/
|
||||||
|
static AsyncHttpRequestCustomizer fromSync(SyncHttpRequestCustomizer customizer) {
|
||||||
|
return (builder, method, uri, body) -> Mono.fromSupplier(() -> {
|
||||||
|
customizer.customize(builder, method, uri, body);
|
||||||
|
return builder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Noop implements AsyncHttpRequestCustomizer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
|
||||||
|
String body) {
|
||||||
|
return Mono.just(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2024 - 2024 the original author or authors.
|
* Copyright 2024 - 2025 the original author or authors.
|
||||||
*/
|
*/
|
||||||
package io.modelcontextprotocol.client.transport;
|
package io.modelcontextprotocol.client.transport;
|
||||||
|
|
||||||
|
|
@ -102,6 +102,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
||||||
*/
|
*/
|
||||||
protected final Sinks.One<String> messageEndpointSink = Sinks.one();
|
protected final Sinks.One<String> messageEndpointSink = Sinks.one();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customizer to modify requests before they are executed.
|
||||||
|
*/
|
||||||
|
private final AsyncHttpRequestCustomizer httpRequestCustomizer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new transport instance with default HTTP client and object mapper.
|
* Creates a new transport instance with default HTTP client and object mapper.
|
||||||
* @param baseUri the base URI of the MCP server
|
* @param baseUri the base URI of the MCP server
|
||||||
|
|
@ -172,18 +177,38 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
||||||
* @param objectMapper the object mapper for JSON serialization/deserialization
|
* @param objectMapper the object mapper for JSON serialization/deserialization
|
||||||
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
|
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(forRemoval = true)
|
||||||
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
|
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
|
||||||
String sseEndpoint, ObjectMapper objectMapper) {
|
String sseEndpoint, ObjectMapper objectMapper) {
|
||||||
|
this(httpClient, requestBuilder, baseUri, sseEndpoint, objectMapper, AsyncHttpRequestCustomizer.NOOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new transport instance with custom HTTP client builder, object mapper,
|
||||||
|
* and headers.
|
||||||
|
* @param httpClient the HTTP client to use
|
||||||
|
* @param requestBuilder the HTTP request builder to use
|
||||||
|
* @param baseUri the base URI of the MCP server
|
||||||
|
* @param sseEndpoint the SSE endpoint path
|
||||||
|
* @param objectMapper the object mapper for JSON serialization/deserialization
|
||||||
|
* @param httpRequestCustomizer customizer for the requestBuilder before executing
|
||||||
|
* requests
|
||||||
|
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
|
||||||
|
*/
|
||||||
|
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
|
||||||
|
String sseEndpoint, ObjectMapper objectMapper, AsyncHttpRequestCustomizer httpRequestCustomizer) {
|
||||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||||
Assert.hasText(baseUri, "baseUri must not be empty");
|
Assert.hasText(baseUri, "baseUri must not be empty");
|
||||||
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
|
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
|
||||||
Assert.notNull(httpClient, "httpClient must not be null");
|
Assert.notNull(httpClient, "httpClient must not be null");
|
||||||
Assert.notNull(requestBuilder, "requestBuilder must not be null");
|
Assert.notNull(requestBuilder, "requestBuilder must not be null");
|
||||||
|
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
|
||||||
this.baseUri = URI.create(baseUri);
|
this.baseUri = URI.create(baseUri);
|
||||||
this.sseEndpoint = sseEndpoint;
|
this.sseEndpoint = sseEndpoint;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
this.requestBuilder = requestBuilder;
|
this.requestBuilder = requestBuilder;
|
||||||
|
this.httpRequestCustomizer = httpRequestCustomizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -213,6 +238,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
||||||
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
private AsyncHttpRequestCustomizer httpRequestCustomizer = AsyncHttpRequestCustomizer.NOOP;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new builder instance.
|
* Creates a new builder instance.
|
||||||
*/
|
*/
|
||||||
|
|
@ -310,31 +337,66 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||||
|
* executing them.
|
||||||
|
* <p>
|
||||||
|
* This overrides the customizer from
|
||||||
|
* {@link #asyncHttpRequestCustomizer(AsyncHttpRequestCustomizer)}.
|
||||||
|
* <p>
|
||||||
|
* Do NOT use a blocking {@link SyncHttpRequestCustomizer} in a non-blocking
|
||||||
|
* context. Use {@link #asyncHttpRequestCustomizer(AsyncHttpRequestCustomizer)}
|
||||||
|
* instead.
|
||||||
|
* @param syncHttpRequestCustomizer the request customizer
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder httpRequestCustomizer(SyncHttpRequestCustomizer syncHttpRequestCustomizer) {
|
||||||
|
this.httpRequestCustomizer = AsyncHttpRequestCustomizer.fromSync(syncHttpRequestCustomizer);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||||
|
* executing them.
|
||||||
|
* <p>
|
||||||
|
* This overrides the customizer from
|
||||||
|
* {@link #httpRequestCustomizer(SyncHttpRequestCustomizer)}.
|
||||||
|
* <p>
|
||||||
|
* Do NOT use a blocking implementation in a non-blocking context.
|
||||||
|
* @param asyncHttpRequestCustomizer the request customizer
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder asyncHttpRequestCustomizer(AsyncHttpRequestCustomizer asyncHttpRequestCustomizer) {
|
||||||
|
this.httpRequestCustomizer = asyncHttpRequestCustomizer;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a new {@link HttpClientSseClientTransport} instance.
|
* Builds a new {@link HttpClientSseClientTransport} instance.
|
||||||
* @return a new transport instance
|
* @return a new transport instance
|
||||||
*/
|
*/
|
||||||
public HttpClientSseClientTransport build() {
|
public HttpClientSseClientTransport build() {
|
||||||
return new HttpClientSseClientTransport(clientBuilder.build(), requestBuilder, baseUri, sseEndpoint,
|
return new HttpClientSseClientTransport(clientBuilder.build(), requestBuilder, baseUri, sseEndpoint,
|
||||||
objectMapper);
|
objectMapper, httpRequestCustomizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||||
|
var uri = Utils.resolveUri(this.baseUri, this.sseEndpoint);
|
||||||
|
|
||||||
return Mono.create(sink -> {
|
return Mono.defer(() -> {
|
||||||
|
var builder = requestBuilder.copy()
|
||||||
HttpRequest request = requestBuilder.copy()
|
.uri(uri)
|
||||||
.uri(Utils.resolveUri(this.baseUri, this.sseEndpoint))
|
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
.header("Cache-Control", "no-cache")
|
.header("Cache-Control", "no-cache")
|
||||||
.GET()
|
.GET();
|
||||||
.build();
|
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null));
|
||||||
|
}).flatMap(requestBuilder -> Mono.create(sink -> {
|
||||||
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
|
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
|
||||||
.sendAsync(request, responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
|
.sendAsync(requestBuilder.build(),
|
||||||
|
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
|
||||||
.exceptionallyCompose(e -> {
|
.exceptionallyCompose(e -> {
|
||||||
sseSink.error(e);
|
sseSink.error(e);
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
|
|
@ -397,7 +459,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
this.sseSubscription.set(connection);
|
this.sseSubscription.set(connection);
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -453,13 +515,13 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
||||||
|
|
||||||
private Mono<HttpResponse<String>> sendHttpPost(final String endpoint, final String body) {
|
private Mono<HttpResponse<String>> sendHttpPost(final String endpoint, final String body) {
|
||||||
final URI requestUri = Utils.resolveUri(baseUri, endpoint);
|
final URI requestUri = Utils.resolveUri(baseUri, endpoint);
|
||||||
final HttpRequest request = this.requestBuilder.copy()
|
return Mono.defer(() -> {
|
||||||
.uri(requestUri)
|
var builder = this.requestBuilder.copy().uri(requestUri).POST(HttpRequest.BodyPublishers.ofString(body));
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body));
|
||||||
.build();
|
}).flatMap(customizedBuilder -> {
|
||||||
|
var request = customizedBuilder.build();
|
||||||
// TODO: why discard the body?
|
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
|
||||||
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright 2024-2024 the original author or authors.
|
* Copyright 2024-2025 the original author or authors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.modelcontextprotocol.client.transport;
|
package io.modelcontextprotocol.client.transport;
|
||||||
|
|
@ -109,6 +109,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
|
|
||||||
private final boolean resumableStreams;
|
private final boolean resumableStreams;
|
||||||
|
|
||||||
|
private final AsyncHttpRequestCustomizer httpRequestCustomizer;
|
||||||
|
|
||||||
private final AtomicReference<DefaultMcpTransportSession> activeSession = new AtomicReference<>();
|
private final AtomicReference<DefaultMcpTransportSession> activeSession = new AtomicReference<>();
|
||||||
|
|
||||||
private final AtomicReference<Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>>> handler = new AtomicReference<>();
|
private final AtomicReference<Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>>> handler = new AtomicReference<>();
|
||||||
|
|
@ -117,7 +119,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
|
|
||||||
private HttpClientStreamableHttpTransport(ObjectMapper objectMapper, HttpClient httpClient,
|
private HttpClientStreamableHttpTransport(ObjectMapper objectMapper, HttpClient httpClient,
|
||||||
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
|
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
|
||||||
boolean openConnectionOnStartup) {
|
boolean openConnectionOnStartup, AsyncHttpRequestCustomizer httpRequestCustomizer) {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
this.requestBuilder = requestBuilder;
|
this.requestBuilder = requestBuilder;
|
||||||
|
|
@ -126,6 +128,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
this.resumableStreams = resumableStreams;
|
this.resumableStreams = resumableStreams;
|
||||||
this.openConnectionOnStartup = openConnectionOnStartup;
|
this.openConnectionOnStartup = openConnectionOnStartup;
|
||||||
this.activeSession.set(createTransportSession());
|
this.activeSession.set(createTransportSession());
|
||||||
|
this.httpRequestCustomizer = httpRequestCustomizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Builder builder(String baseUri) {
|
public static Builder builder(String baseUri) {
|
||||||
|
|
@ -154,14 +157,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
private Publisher<Void> createDelete(String sessionId) {
|
private Publisher<Void> createDelete(String sessionId) {
|
||||||
HttpRequest request = this.requestBuilder.copy()
|
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
|
||||||
.uri(Utils.resolveUri(this.baseUri, this.endpoint))
|
return Mono.defer(() -> {
|
||||||
.header("Cache-Control", "no-cache")
|
var builder = this.requestBuilder.copy()
|
||||||
.header("mcp-session-id", sessionId)
|
.uri(uri)
|
||||||
.DELETE()
|
.header("Cache-Control", "no-cache")
|
||||||
.build();
|
.header("mcp-session-id", sessionId)
|
||||||
|
.DELETE();
|
||||||
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())).then();
|
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null));
|
||||||
|
}).flatMap(requestBuilder -> {
|
||||||
|
var request = requestBuilder.build();
|
||||||
|
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
|
||||||
|
}).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -208,100 +215,110 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
|
|
||||||
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
||||||
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
||||||
|
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
|
||||||
|
|
||||||
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
|
Disposable connection = Mono.defer(() -> {
|
||||||
|
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
|
||||||
|
|
||||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||||
requestBuilder = requestBuilder.header("mcp-session-id", transportSession.sessionId().get());
|
requestBuilder = requestBuilder.header("mcp-session-id", transportSession.sessionId().get());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stream != null && stream.lastId().isPresent()) {
|
if (stream != null && stream.lastId().isPresent()) {
|
||||||
requestBuilder = requestBuilder.header("last-event-id", stream.lastId().get());
|
requestBuilder = requestBuilder.header("last-event-id", stream.lastId().get());
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpRequest request = requestBuilder.uri(Utils.resolveUri(this.baseUri, this.endpoint))
|
var builder = requestBuilder.uri(uri)
|
||||||
.header("Accept", TEXT_EVENT_STREAM)
|
.header("Accept", TEXT_EVENT_STREAM)
|
||||||
.header("Cache-Control", "no-cache")
|
.header("Cache-Control", "no-cache")
|
||||||
.GET()
|
.GET();
|
||||||
.build();
|
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null));
|
||||||
|
})
|
||||||
|
.flatMapMany(
|
||||||
|
requestBuilder -> Flux.<ResponseEvent>create(
|
||||||
|
sseSink -> this.httpClient
|
||||||
|
.sendAsync(requestBuilder.build(),
|
||||||
|
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo,
|
||||||
|
sseSink))
|
||||||
|
.whenComplete((response, throwable) -> {
|
||||||
|
if (throwable != null) {
|
||||||
|
sseSink.error(throwable);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.debug("SSE connection established successfully");
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
|
||||||
|
.flatMap(responseEvent -> {
|
||||||
|
int statusCode = responseEvent.responseInfo().statusCode();
|
||||||
|
|
||||||
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
.sendAsync(request, responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
|
|
||||||
.whenComplete((response, throwable) -> {
|
|
||||||
if (throwable != null) {
|
|
||||||
sseSink.error(throwable);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
logger.debug("SSE connection established successfully");
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
|
|
||||||
.flatMap(responseEvent -> {
|
|
||||||
int statusCode = responseEvent.responseInfo().statusCode();
|
|
||||||
|
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
|
||||||
|
try {
|
||||||
|
// We don't support batching ATM and probably
|
||||||
|
// won't since the next version considers
|
||||||
|
// removing it.
|
||||||
|
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(
|
||||||
|
this.objectMapper, responseEvent.sseEvent().data());
|
||||||
|
|
||||||
if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
|
Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> idWithMessages = Tuples
|
||||||
try {
|
.of(Optional.ofNullable(responseEvent.sseEvent().id()),
|
||||||
// We don't support batching ATM and probably won't since
|
List.of(message));
|
||||||
// the
|
|
||||||
// next version considers removing it.
|
|
||||||
McpSchema.JSONRPCMessage message = McpSchema
|
|
||||||
.deserializeJsonRpcMessage(this.objectMapper, responseEvent.sseEvent().data());
|
|
||||||
|
|
||||||
Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> idWithMessages = Tuples
|
McpTransportStream<Disposable> sessionStream = stream != null ? stream
|
||||||
.of(Optional.ofNullable(responseEvent.sseEvent().id()), List.of(message));
|
: new DefaultMcpTransportStream<>(this.resumableStreams,
|
||||||
|
this::reconnect);
|
||||||
|
logger.debug("Connected stream {}", sessionStream.streamId());
|
||||||
|
|
||||||
McpTransportStream<Disposable> sessionStream = stream != null ? stream
|
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
||||||
: new DefaultMcpTransportStream<>(this.resumableStreams, this::reconnect);
|
|
||||||
logger.debug("Connected stream {}", sessionStream.streamId());
|
|
||||||
|
|
||||||
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
}
|
||||||
|
catch (IOException ioException) {
|
||||||
|
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||||
|
new McpError("Error parsing JSON-RPC message: "
|
||||||
|
+ responseEvent.sseEvent().data()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.debug("Received SSE event with type: {}", responseEvent.sseEvent());
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
|
||||||
|
logger
|
||||||
|
.debug("The server does not support SSE streams, using request-response mode.");
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
else if (statusCode == NOT_FOUND) {
|
||||||
|
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||||
|
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||||
|
"Session not found for session ID: " + sessionIdRepresentation);
|
||||||
|
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||||
|
}
|
||||||
|
else if (statusCode == BAD_REQUEST) {
|
||||||
|
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||||
|
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||||
|
"Session not found for session ID: " + sessionIdRepresentation);
|
||||||
|
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
catch (IOException ioException) {
|
|
||||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpError(
|
return Flux.<McpSchema.JSONRPCMessage>error(new McpError(
|
||||||
"Error parsing JSON-RPC message: " + responseEvent.sseEvent().data()));
|
"Received unrecognized SSE event type: " + responseEvent.sseEvent().event()));
|
||||||
}
|
}).<McpSchema
|
||||||
}
|
.JSONRPCMessage>flatMap(
|
||||||
else {
|
jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
|
||||||
logger.debug("Received SSE event with type: {}", responseEvent.sseEvent());
|
.onErrorMap(CompletionException.class, t -> t.getCause())
|
||||||
return Flux.empty();
|
.onErrorComplete(t -> {
|
||||||
}
|
this.handleException(t);
|
||||||
}
|
return true;
|
||||||
else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
|
})
|
||||||
logger.debug("The server does not support SSE streams, using request-response mode.");
|
.doFinally(s -> {
|
||||||
return Flux.empty();
|
Disposable ref = disposableRef.getAndSet(null);
|
||||||
}
|
if (ref != null) {
|
||||||
else if (statusCode == NOT_FOUND) {
|
transportSession.removeConnection(ref);
|
||||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
}
|
||||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
}))
|
||||||
"Session not found for session ID: " + sessionIdRepresentation);
|
|
||||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
|
||||||
}
|
|
||||||
else if (statusCode == BAD_REQUEST) {
|
|
||||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
|
||||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
|
||||||
"Session not found for session ID: " + sessionIdRepresentation);
|
|
||||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
|
||||||
new McpError("Received unrecognized SSE event type: " + responseEvent.sseEvent().event()));
|
|
||||||
|
|
||||||
}).<McpSchema
|
|
||||||
.JSONRPCMessage>flatMap(jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
|
|
||||||
.onErrorMap(CompletionException.class, t -> t.getCause())
|
|
||||||
.onErrorComplete(t -> {
|
|
||||||
this.handleException(t);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.doFinally(s -> {
|
|
||||||
Disposable ref = disposableRef.getAndSet(null);
|
|
||||||
if (ref != null) {
|
|
||||||
transportSession.removeConnection(ref);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.contextWrite(ctx)
|
.contextWrite(ctx)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
|
|
@ -347,31 +364,33 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
|
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
|
||||||
return Mono.create(messageSink -> {
|
return Mono.create(deliveredSink -> {
|
||||||
logger.debug("Sending message {}", sentMessage);
|
logger.debug("Sending message {}", sentMessage);
|
||||||
|
|
||||||
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
||||||
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
||||||
|
|
||||||
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
|
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
|
||||||
|
|
||||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
|
||||||
requestBuilder = requestBuilder.header("mcp-session-id", transportSession.sessionId().get());
|
|
||||||
}
|
|
||||||
|
|
||||||
String jsonBody = this.toString(sentMessage);
|
String jsonBody = this.toString(sentMessage);
|
||||||
|
|
||||||
HttpRequest request = requestBuilder.uri(Utils.resolveUri(this.baseUri, this.endpoint))
|
Disposable connection = Mono.defer(() -> {
|
||||||
.header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
|
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
|
||||||
.header("Content-Type", APPLICATION_JSON)
|
|
||||||
.header("Cache-Control", "no-cache")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Disposable connection = Flux.<ResponseEvent>create(responseEventSink -> {
|
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||||
|
requestBuilder = requestBuilder.header("mcp-session-id", transportSession.sessionId().get());
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = requestBuilder.uri(uri)
|
||||||
|
.header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
|
||||||
|
.header("Content-Type", APPLICATION_JSON)
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||||
|
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, jsonBody));
|
||||||
|
}).flatMapMany(requestBuilder -> Flux.<ResponseEvent>create(responseEventSink -> {
|
||||||
|
|
||||||
// Create the async request with proper body subscriber selection
|
// Create the async request with proper body subscriber selection
|
||||||
Mono.fromFuture(this.httpClient.sendAsync(request, this.toSendMessageBodySubscriber(responseEventSink))
|
Mono.fromFuture(this.httpClient
|
||||||
|
.sendAsync(requestBuilder.build(), this.toSendMessageBodySubscriber(responseEventSink))
|
||||||
.whenComplete((response, throwable) -> {
|
.whenComplete((response, throwable) -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
responseEventSink.error(throwable);
|
responseEventSink.error(throwable);
|
||||||
|
|
@ -381,13 +400,13 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
}
|
}
|
||||||
})).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe();
|
})).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe();
|
||||||
|
|
||||||
}).flatMap(responseEvent -> {
|
})).flatMap(responseEvent -> {
|
||||||
if (transportSession.markInitialized(
|
if (transportSession.markInitialized(
|
||||||
responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
|
responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
|
||||||
// Once we have a session, we try to open an async stream for
|
// Once we have a session, we try to open an async stream for
|
||||||
// the server to send notifications and requests out-of-band.
|
// the server to send notifications and requests out-of-band.
|
||||||
|
|
||||||
reconnect(null).contextWrite(messageSink.contextView()).subscribe();
|
reconnect(null).contextWrite(deliveredSink.contextView()).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
String sessionRepresentation = sessionIdOrPlaceholder(transportSession);
|
String sessionRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||||
|
|
@ -404,16 +423,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
|
|
||||||
if (contentType.isBlank()) {
|
if (contentType.isBlank()) {
|
||||||
logger.debug("No content type returned for POST in session {}", sessionRepresentation);
|
logger.debug("No content type returned for POST in session {}", sessionRepresentation);
|
||||||
// No content type means no response body, so we can just return
|
// No content type means no response body, so we can just
|
||||||
|
// return
|
||||||
// an empty stream
|
// an empty stream
|
||||||
messageSink.success();
|
deliveredSink.success();
|
||||||
return Flux.empty();
|
return Flux.empty();
|
||||||
}
|
}
|
||||||
else if (contentType.contains(TEXT_EVENT_STREAM)) {
|
else if (contentType.contains(TEXT_EVENT_STREAM)) {
|
||||||
return Flux.just(((ResponseSubscribers.SseResponseEvent) responseEvent).sseEvent())
|
return Flux.just(((ResponseSubscribers.SseResponseEvent) responseEvent).sseEvent())
|
||||||
.flatMap(sseEvent -> {
|
.flatMap(sseEvent -> {
|
||||||
try {
|
try {
|
||||||
// We don't support batching ATM and probably won't
|
// We don't support batching ATM and probably
|
||||||
|
// won't
|
||||||
// since the
|
// since the
|
||||||
// next version considers removing it.
|
// next version considers removing it.
|
||||||
McpSchema.JSONRPCMessage message = McpSchema
|
McpSchema.JSONRPCMessage message = McpSchema
|
||||||
|
|
@ -427,7 +448,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
|
|
||||||
logger.debug("Connected stream {}", sessionStream.streamId());
|
logger.debug("Connected stream {}", sessionStream.streamId());
|
||||||
|
|
||||||
messageSink.success();
|
deliveredSink.success();
|
||||||
|
|
||||||
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
||||||
}
|
}
|
||||||
|
|
@ -438,7 +459,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (contentType.contains(APPLICATION_JSON)) {
|
else if (contentType.contains(APPLICATION_JSON)) {
|
||||||
messageSink.success();
|
deliveredSink.success();
|
||||||
String data = ((ResponseSubscribers.AggregateResponseEvent) responseEvent).data();
|
String data = ((ResponseSubscribers.AggregateResponseEvent) responseEvent).data();
|
||||||
if (sentMessage instanceof McpSchema.JSONRPCNotification && Utils.hasText(data)) {
|
if (sentMessage instanceof McpSchema.JSONRPCNotification && Utils.hasText(data)) {
|
||||||
logger.warn("Notification: {} received non-compliant response: {}", sentMessage, data);
|
logger.warn("Notification: {} received non-compliant response: {}", sentMessage, data);
|
||||||
|
|
@ -483,7 +504,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
// handle the error first
|
// handle the error first
|
||||||
this.handleException(t);
|
this.handleException(t);
|
||||||
// inform the caller of sendMessage
|
// inform the caller of sendMessage
|
||||||
messageSink.error(t);
|
deliveredSink.error(t);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.doFinally(s -> {
|
.doFinally(s -> {
|
||||||
|
|
@ -493,7 +514,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
transportSession.removeConnection(ref);
|
transportSession.removeConnection(ref);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.contextWrite(messageSink.contextView())
|
.contextWrite(deliveredSink.contextView())
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
disposableRef.set(connection);
|
disposableRef.set(connection);
|
||||||
|
|
@ -531,6 +552,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
|
|
||||||
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
|
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
|
||||||
|
|
||||||
|
private AsyncHttpRequestCustomizer httpRequestCustomizer = AsyncHttpRequestCustomizer.NOOP;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new builder with the specified base URI.
|
* Creates a new builder with the specified base URI.
|
||||||
* @param baseUri the base URI of the MCP server
|
* @param baseUri the base URI of the MCP server
|
||||||
|
|
@ -633,6 +656,40 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||||
|
* executing them.
|
||||||
|
* <p>
|
||||||
|
* This overrides the customizer from
|
||||||
|
* {@link #asyncHttpRequestCustomizer(AsyncHttpRequestCustomizer)}.
|
||||||
|
* <p>
|
||||||
|
* Do NOT use a blocking {@link SyncHttpRequestCustomizer} in a non-blocking
|
||||||
|
* context. Use {@link #asyncHttpRequestCustomizer(AsyncHttpRequestCustomizer)}
|
||||||
|
* instead.
|
||||||
|
* @param syncHttpRequestCustomizer the request customizer
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder httpRequestCustomizer(SyncHttpRequestCustomizer syncHttpRequestCustomizer) {
|
||||||
|
this.httpRequestCustomizer = AsyncHttpRequestCustomizer.fromSync(syncHttpRequestCustomizer);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||||
|
* executing them.
|
||||||
|
* <p>
|
||||||
|
* This overrides the customizer from
|
||||||
|
* {@link #httpRequestCustomizer(SyncHttpRequestCustomizer)}.
|
||||||
|
* <p>
|
||||||
|
* Do NOT use a blocking implementation in a non-blocking context.
|
||||||
|
* @param asyncHttpRequestCustomizer the request customizer
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder asyncHttpRequestCustomizer(AsyncHttpRequestCustomizer asyncHttpRequestCustomizer) {
|
||||||
|
this.httpRequestCustomizer = asyncHttpRequestCustomizer;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
|
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
|
||||||
* the current builder configuration.
|
* the current builder configuration.
|
||||||
|
|
@ -642,7 +699,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||||
ObjectMapper objectMapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
|
ObjectMapper objectMapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
|
||||||
|
|
||||||
return new HttpClientStreamableHttpTransport(objectMapper, clientBuilder.build(), requestBuilder, baseUri,
|
return new HttpClientStreamableHttpTransport(objectMapper, clientBuilder.build(), requestBuilder, baseUri,
|
||||||
endpoint, resumableStreams, openConnectionOnStartup);
|
endpoint, resumableStreams, openConnectionOnStartup, httpRequestCustomizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024-2025 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.client.transport;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import reactor.util.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize {@link HttpRequest.Builder} before executing the request, either in SSE or
|
||||||
|
* Streamable HTTP transport.
|
||||||
|
*
|
||||||
|
* @author Daniel Garnier-Moiroux
|
||||||
|
*/
|
||||||
|
public interface SyncHttpRequestCustomizer {
|
||||||
|
|
||||||
|
void customize(HttpRequest.Builder builder, String method, URI endpoint, @Nullable String body);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -17,10 +17,13 @@ import java.util.function.Function;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.modelcontextprotocol.spec.McpSchema;
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
|
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.Timeout;
|
import org.junit.jupiter.api.Timeout;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.testcontainers.containers.GenericContainer;
|
import org.testcontainers.containers.GenericContainer;
|
||||||
import org.testcontainers.containers.wait.strategy.Wait;
|
import org.testcontainers.containers.wait.strategy.Wait;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
@ -28,9 +31,17 @@ import reactor.core.publisher.Sinks;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
import org.springframework.http.codec.ServerSentEvent;
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.clearInvocations;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for the {@link HttpClientSseClientTransport} class.
|
* Tests for the {@link HttpClientSseClientTransport} class.
|
||||||
|
|
@ -43,7 +54,7 @@ class HttpClientSseClientTransportTests {
|
||||||
static String host = "http://localhost:3001";
|
static String host = "http://localhost:3001";
|
||||||
|
|
||||||
@SuppressWarnings("resource")
|
@SuppressWarnings("resource")
|
||||||
GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
|
static GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
|
||||||
.withCommand("node dist/index.js sse")
|
.withCommand("node dist/index.js sse")
|
||||||
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
|
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
|
||||||
.withExposedPorts(3001)
|
.withExposedPorts(3001)
|
||||||
|
|
@ -61,7 +72,7 @@ class HttpClientSseClientTransportTests {
|
||||||
public TestHttpClientSseClientTransport(final String baseUri) {
|
public TestHttpClientSseClientTransport(final String baseUri) {
|
||||||
super(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(),
|
super(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(),
|
||||||
HttpRequest.newBuilder().header("Content-Type", "application/json"), baseUri, "/sse",
|
HttpRequest.newBuilder().header("Content-Type", "application/json"), baseUri, "/sse",
|
||||||
new ObjectMapper());
|
new ObjectMapper(), AsyncHttpRequestCustomizer.NOOP);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getInboundMessageCount() {
|
public int getInboundMessageCount() {
|
||||||
|
|
@ -80,15 +91,21 @@ class HttpClientSseClientTransportTests {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void startContainer() {
|
@BeforeAll
|
||||||
|
static void startContainer() {
|
||||||
container.start();
|
container.start();
|
||||||
int port = container.getMappedPort(3001);
|
int port = container.getMappedPort(3001);
|
||||||
host = "http://" + container.getHost() + ":" + port;
|
host = "http://" + container.getHost() + ":" + port;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopContainer() {
|
||||||
|
container.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
startContainer();
|
|
||||||
transport = new TestHttpClientSseClientTransport(host);
|
transport = new TestHttpClientSseClientTransport(host);
|
||||||
transport.connect(Function.identity()).block();
|
transport.connect(Function.identity()).block();
|
||||||
}
|
}
|
||||||
|
|
@ -98,11 +115,6 @@ class HttpClientSseClientTransportTests {
|
||||||
if (transport != null) {
|
if (transport != null) {
|
||||||
assertThatCode(() -> transport.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
assertThatCode(() -> transport.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
void cleanup() {
|
|
||||||
container.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -375,4 +387,74 @@ class HttpClientSseClientTransportTests {
|
||||||
customizedTransport.closeGracefully().block();
|
customizedTransport.closeGracefully().block();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRequestCustomizer() {
|
||||||
|
var mockCustomizer = mock(SyncHttpRequestCustomizer.class);
|
||||||
|
|
||||||
|
// Create a transport with the customizer
|
||||||
|
var customizedTransport = HttpClientSseClientTransport.builder(host)
|
||||||
|
.httpRequestCustomizer(mockCustomizer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
StepVerifier.create(customizedTransport.connect(Function.identity())).verifyComplete();
|
||||||
|
|
||||||
|
// Verify the customizer was called
|
||||||
|
verify(mockCustomizer).customize(any(), eq("GET"),
|
||||||
|
eq(UriComponentsBuilder.fromUriString(host).path("/sse").build().toUri()), isNull());
|
||||||
|
clearInvocations(mockCustomizer);
|
||||||
|
|
||||||
|
// Send test message
|
||||||
|
var testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
|
||||||
|
Map.of("key", "value"));
|
||||||
|
|
||||||
|
// Subscribe to messages and verify
|
||||||
|
StepVerifier.create(customizedTransport.sendMessage(testMessage)).verifyComplete();
|
||||||
|
|
||||||
|
// Verify the customizer was called
|
||||||
|
var uriArgumentCaptor = ArgumentCaptor.forClass(URI.class);
|
||||||
|
verify(mockCustomizer).customize(any(), eq("POST"), uriArgumentCaptor.capture(), eq(
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"method\":\"test-method\",\"id\":\"test-id\",\"params\":{\"key\":\"value\"}}"));
|
||||||
|
assertThat(uriArgumentCaptor.getValue().toString()).startsWith(host + "/message?sessionId=");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
customizedTransport.closeGracefully().block();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAsyncRequestCustomizer() {
|
||||||
|
var mockCustomizer = mock(AsyncHttpRequestCustomizer.class);
|
||||||
|
when(mockCustomizer.customize(any(), any(), any(), any()))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArguments()[0]));
|
||||||
|
|
||||||
|
// Create a transport with the customizer
|
||||||
|
var customizedTransport = HttpClientSseClientTransport.builder(host)
|
||||||
|
.asyncHttpRequestCustomizer(mockCustomizer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
StepVerifier.create(customizedTransport.connect(Function.identity())).verifyComplete();
|
||||||
|
|
||||||
|
// Verify the customizer was called
|
||||||
|
verify(mockCustomizer).customize(any(), eq("GET"),
|
||||||
|
eq(UriComponentsBuilder.fromUriString(host).path("/sse").build().toUri()), isNull());
|
||||||
|
clearInvocations(mockCustomizer);
|
||||||
|
|
||||||
|
// Send test message
|
||||||
|
var testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
|
||||||
|
Map.of("key", "value"));
|
||||||
|
|
||||||
|
// Subscribe to messages and verify
|
||||||
|
StepVerifier.create(customizedTransport.sendMessage(testMessage)).verifyComplete();
|
||||||
|
|
||||||
|
// Verify the customizer was called
|
||||||
|
var uriArgumentCaptor = ArgumentCaptor.forClass(URI.class);
|
||||||
|
verify(mockCustomizer).customize(any(), eq("POST"), uriArgumentCaptor.capture(), eq(
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"method\":\"test-method\",\"id\":\"test-id\",\"params\":{\"key\":\"value\"}}"));
|
||||||
|
assertThat(uriArgumentCaptor.getValue().toString()).startsWith(host + "/message?sessionId=");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
customizedTransport.closeGracefully().block();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024-2025 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.client.transport;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.testcontainers.containers.GenericContainer;
|
||||||
|
import org.testcontainers.containers.wait.strategy.Wait;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the {@link HttpClientStreamableHttpTransport} class.
|
||||||
|
*
|
||||||
|
* @author Daniel Garnier-Moiroux
|
||||||
|
*/
|
||||||
|
class HttpClientStreamableHttpTransportTest {
|
||||||
|
|
||||||
|
static String host = "http://localhost:3001";
|
||||||
|
|
||||||
|
@SuppressWarnings("resource")
|
||||||
|
static GenericContainer<?> container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v2")
|
||||||
|
.withCommand("node dist/index.js streamableHttp")
|
||||||
|
.withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String()))
|
||||||
|
.withExposedPorts(3001)
|
||||||
|
.waitingFor(Wait.forHttp("/").forStatusCode(404));
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startContainer() {
|
||||||
|
container.start();
|
||||||
|
int port = container.getMappedPort(3001);
|
||||||
|
host = "http://" + container.getHost() + ":" + port;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopContainer() {
|
||||||
|
container.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void withTransport(HttpClientStreamableHttpTransport transport, Consumer<HttpClientStreamableHttpTransport> c) {
|
||||||
|
try {
|
||||||
|
c.accept(transport);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRequestCustomizer() throws URISyntaxException {
|
||||||
|
var uri = new URI(host + "/mcp");
|
||||||
|
var mockRequestCustomizer = mock(SyncHttpRequestCustomizer.class);
|
||||||
|
|
||||||
|
var transport = HttpClientStreamableHttpTransport.builder(host)
|
||||||
|
.httpRequestCustomizer(mockRequestCustomizer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
withTransport(transport, (t) -> {
|
||||||
|
// Send test message
|
||||||
|
var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION,
|
||||||
|
McpSchema.ClientCapabilities.builder().roots(true).build(),
|
||||||
|
new McpSchema.Implementation("Spring AI MCP Client", "0.3.1"));
|
||||||
|
var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE,
|
||||||
|
"test-id", initializeRequest);
|
||||||
|
|
||||||
|
StepVerifier.create(t.sendMessage(testMessage)).verifyComplete();
|
||||||
|
|
||||||
|
// Verify the customizer was called
|
||||||
|
verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("GET"), eq(uri), eq(
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAsyncRequestCustomizer() throws URISyntaxException {
|
||||||
|
var uri = new URI(host + "/mcp");
|
||||||
|
var mockRequestCustomizer = mock(AsyncHttpRequestCustomizer.class);
|
||||||
|
when(mockRequestCustomizer.customize(any(), any(), any(), any()))
|
||||||
|
.thenAnswer(invocation -> Mono.just(invocation.getArguments()[0]));
|
||||||
|
|
||||||
|
var transport = HttpClientStreamableHttpTransport.builder(host)
|
||||||
|
.asyncHttpRequestCustomizer(mockRequestCustomizer)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
withTransport(transport, (t) -> {
|
||||||
|
// Send test message
|
||||||
|
var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION,
|
||||||
|
McpSchema.ClientCapabilities.builder().roots(true).build(),
|
||||||
|
new McpSchema.Implementation("Spring AI MCP Client", "0.3.1"));
|
||||||
|
var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE,
|
||||||
|
"test-id", initializeRequest);
|
||||||
|
|
||||||
|
StepVerifier.create(t.sendMessage(testMessage)).verifyComplete();
|
||||||
|
|
||||||
|
// Verify the customizer was called
|
||||||
|
verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("GET"), eq(uri), eq(
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue