diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 51369c387..92d38b1cc 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -213,7 +213,7 @@ Schemas that explicitly declare a different dialect via `$schema` are accepted w The deprecated `Builder.customizeRequest(Consumer)` method on `HttpClientSseClientTransport` and `HttpClientStreamableHttpTransport` has been removed. -**Action:** Use `requestBuilder(HttpRequest.Builder)` for static request setup, or `httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)` for per-request customization. +**Action:** Use `requestBuilder(HttpRequest.Builder)` for static request setup, or `addHttpRequestCustomizer(McpSyncHttpClientRequestCustomizer)` for per-request customization. ### `protocolVersions()` default now advertises all known versions diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java index e783a9197..ecfd572ce 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java @@ -55,7 +55,7 @@ public void execute(String serverUrl) { var customizer = new OAuth2ClientCredentialsSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID); HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) - .httpRequestCustomizer(customizer) + .addHttpRequestCustomizer(customizer) .build(); var client = McpClient.sync(transport) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index 9ed5c5cd4..6bf1a2424 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -8,14 +8,15 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; +import io.modelcontextprotocol.client.transport.customizer.DelegatingMcpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; @@ -203,7 +204,7 @@ public static class Builder { private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(); - private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.NOOP; + private final List httpRequestCustomizers = new ArrayList<>(); private Duration connectTimeout = Duration.ofSeconds(10); @@ -286,10 +287,10 @@ public Builder jsonMapper(McpJsonMapper jsonMapper) { /** * Sets the customizer for {@link HttpRequest.Builder}, to modify requests before - * executing them. + * executing them. The customizer is internally converted to + * {@link McpAsyncHttpClientRequestCustomizer}. *

- * This overrides the customizer from - * {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)}. + * This replaces all customizers previously registered on this builder. *

* Do NOT use a blocking {@link McpSyncHttpClientRequestCustomizer} in a * non-blocking context. Use @@ -297,25 +298,81 @@ public Builder jsonMapper(McpJsonMapper jsonMapper) { * instead. * @param syncHttpRequestCustomizer the request customizer * @return this builder + * @deprecated Use {@link #addHttpRequestCustomizer} instead. */ + @Deprecated public Builder httpRequestCustomizer(McpSyncHttpClientRequestCustomizer syncHttpRequestCustomizer) { - this.httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.fromSync(syncHttpRequestCustomizer); - return this; + Assert.notNull(syncHttpRequestCustomizer, "syncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.clear(); + return addHttpRequestCustomizer(syncHttpRequestCustomizer); } /** * Sets the customizer for {@link HttpRequest.Builder}, to modify requests before * executing them. *

- * This overrides the customizer from - * {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}. + * This replaces all customizers previously registered on this builder. *

* Do NOT use a blocking implementation in a non-blocking context. * @param asyncHttpRequestCustomizer the request customizer * @return this builder + * @deprecated Use {@link #addAsyncHttpRequestCustomizer} instead. */ + @Deprecated public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer asyncHttpRequestCustomizer) { - this.httpRequestCustomizer = asyncHttpRequestCustomizer; + Assert.notNull(asyncHttpRequestCustomizer, "asyncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.clear(); + return addAsyncHttpRequestCustomizer(asyncHttpRequestCustomizer); + } + + /** + * Adds a customizer for {@link HttpRequest.Builder}, to modify requests before + * executing them. Customizers are applied in the order they are added, after + * those already registered on this builder. The customizer is internally + * converted to {@link McpAsyncHttpClientRequestCustomizer}. + *

+ * Do NOT use a blocking {@link McpSyncHttpClientRequestCustomizer} in a + * non-blocking context. Use + * {@link #addAsyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} + * instead. + * @param syncHttpRequestCustomizer the request customizer + * @return this builder + */ + public Builder addHttpRequestCustomizer(McpSyncHttpClientRequestCustomizer syncHttpRequestCustomizer) { + Assert.notNull(syncHttpRequestCustomizer, "syncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.add(McpAsyncHttpClientRequestCustomizer.fromSync(syncHttpRequestCustomizer)); + return this; + } + + /** + * Adds a customizer for {@link HttpRequest.Builder}, to modify requests before + * executing them. Customizers are applied in the order they are added, after + * those already registered on this builder. + *

+ * Do NOT use a blocking implementation in a non-blocking context. + * @param asyncHttpRequestCustomizer the request customizer + * @return this builder + */ + public Builder addAsyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer asyncHttpRequestCustomizer) { + Assert.notNull(asyncHttpRequestCustomizer, "asyncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.add(asyncHttpRequestCustomizer); + return this; + } + + /** + * Provides access to the mutable list of request customizers registered on this + * builder, so they can be inspected, reordered, added or removed. Customizers are + * applied in list order. Synchronous customizers registered through + * {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} or + * {@link #addHttpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} appear in + * the list wrapped as {@link McpAsyncHttpClientRequestCustomizer}. + * @param customizersConsumer a consumer of the list of customizers + * @return this builder + */ + public Builder asyncHttpRequestCustomizers( + Consumer> customizersConsumer) { + Assert.notNull(customizersConsumer, "customizersConsumer must not be null"); + customizersConsumer.accept(this.httpRequestCustomizers); return this; } @@ -369,10 +426,19 @@ public Builder maxResponseSize(int maxResponseSize) { public HttpClientSseClientTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint, - jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer, + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer(), messageEndpointValidator, maxResponseSize); } + private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer() { + Assert.noNullElements(this.httpRequestCustomizers, "httpRequestCustomizers must not contain null elements"); + return switch (this.httpRequestCustomizers.size()) { + case 0 -> McpAsyncHttpClientRequestCustomizer.NOOP; + case 1 -> this.httpRequestCustomizers.get(0); + default -> new DelegatingMcpAsyncHttpClientRequestCustomizer(List.copyOf(this.httpRequestCustomizers)); + }; + } + } @Override diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index 9d55e816c..e56a23f08 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -11,6 +11,7 @@ import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -22,6 +23,7 @@ import java.util.function.Function; import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.client.transport.customizer.DelegatingMcpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler; import io.modelcontextprotocol.client.transport.customizer.McpHttpClientTransportAuthorizationErrorHandler; @@ -672,7 +674,7 @@ public static class Builder { private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(); - private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.NOOP; + private final List httpRequestCustomizers = new ArrayList<>(); private Duration connectTimeout = Duration.ofSeconds(10); @@ -776,10 +778,10 @@ public Builder openConnectionOnStartup(boolean openConnectionOnStartup) { /** * Sets the customizer for {@link HttpRequest.Builder}, to modify requests before - * executing them. + * executing them. The customizer is internally converted to + * {@link McpAsyncHttpClientRequestCustomizer}. *

- * This overrides the customizer from - * {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)}. + * This replaces all customizers previously registered on this builder. *

* Do NOT use a blocking {@link McpSyncHttpClientRequestCustomizer} in a * non-blocking context. Use @@ -787,25 +789,81 @@ public Builder openConnectionOnStartup(boolean openConnectionOnStartup) { * instead. * @param syncHttpRequestCustomizer the request customizer * @return this builder + * @deprecated Use {@link #addHttpRequestCustomizer} instead. */ + @Deprecated public Builder httpRequestCustomizer(McpSyncHttpClientRequestCustomizer syncHttpRequestCustomizer) { - this.httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.fromSync(syncHttpRequestCustomizer); - return this; + Assert.notNull(syncHttpRequestCustomizer, "syncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.clear(); + return addHttpRequestCustomizer(syncHttpRequestCustomizer); } /** * Sets the customizer for {@link HttpRequest.Builder}, to modify requests before * executing them. *

- * This overrides the customizer from - * {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}. + * This replaces all customizers previously registered on this builder. *

* Do NOT use a blocking implementation in a non-blocking context. * @param asyncHttpRequestCustomizer the request customizer * @return this builder + * @deprecated Use {@link #addAsyncHttpRequestCustomizer} instead. */ + @Deprecated public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer asyncHttpRequestCustomizer) { - this.httpRequestCustomizer = asyncHttpRequestCustomizer; + Assert.notNull(asyncHttpRequestCustomizer, "asyncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.clear(); + return addAsyncHttpRequestCustomizer(asyncHttpRequestCustomizer); + } + + /** + * Adds a customizer for {@link HttpRequest.Builder}, to modify requests before + * executing them. Customizers are applied in the order they are added, after + * those already registered on this builder. The customizer is internally + * converted to {@link McpAsyncHttpClientRequestCustomizer}. + *

+ * Do NOT use a blocking {@link McpSyncHttpClientRequestCustomizer} in a + * non-blocking context. Use + * {@link #addAsyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} + * instead. + * @param syncHttpRequestCustomizer the request customizer + * @return this builder + */ + public Builder addHttpRequestCustomizer(McpSyncHttpClientRequestCustomizer syncHttpRequestCustomizer) { + Assert.notNull(syncHttpRequestCustomizer, "syncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.add(McpAsyncHttpClientRequestCustomizer.fromSync(syncHttpRequestCustomizer)); + return this; + } + + /** + * Adds a customizer for {@link HttpRequest.Builder}, to modify requests before + * executing them. Customizers are applied in the order they are added, after + * those already registered on this builder. + *

+ * Do NOT use a blocking implementation in a non-blocking context. + * @param asyncHttpRequestCustomizer the request customizer + * @return this builder + */ + public Builder addAsyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer asyncHttpRequestCustomizer) { + Assert.notNull(asyncHttpRequestCustomizer, "asyncHttpRequestCustomizer must not be null"); + this.httpRequestCustomizers.add(asyncHttpRequestCustomizer); + return this; + } + + /** + * Provides access to the mutable list of request customizers registered on this + * builder, so they can be inspected, reordered, added or removed. Customizers are + * applied in list order. Synchronous customizers registered through + * {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} or + * {@link #addHttpRequestCustomizer(McpSyncHttpClientRequestCustomizer)} appear in + * the list wrapped as {@link McpAsyncHttpClientRequestCustomizer}. + * @param customizersConsumer a consumer of the list of customizers + * @return this builder + */ + public Builder asyncHttpRequestCustomizers( + Consumer> customizersConsumer) { + Assert.notNull(customizersConsumer, "customizersConsumer must not be null"); + customizersConsumer.accept(this.httpRequestCustomizers); return this; } @@ -910,7 +968,16 @@ public HttpClientStreamableHttpTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, - httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions, maxResponseSize); + httpRequestCustomizer(), authorizationErrorHandler, supportedProtocolVersions, maxResponseSize); + } + + private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer() { + Assert.noNullElements(this.httpRequestCustomizers, "httpRequestCustomizers must not contain null elements"); + return switch (this.httpRequestCustomizers.size()) { + case 0 -> McpAsyncHttpClientRequestCustomizer.NOOP; + case 1 -> this.httpRequestCustomizers.get(0); + default -> new DelegatingMcpAsyncHttpClientRequestCustomizer(List.copyOf(this.httpRequestCustomizers)); + }; } } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 71df07085..f422871b0 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.time.Duration; import java.util.ArrayList; @@ -23,6 +24,7 @@ import java.util.function.Consumer; import java.util.function.Function; +import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -67,6 +69,15 @@ public abstract class AbstractMcpAsyncClientTests { abstract protected McpClientTransport createMcpTransport(); + // Creates a client transport that applies request customizers, in the order they + // appear in the list. Subclasses whose transport supports + // McpAsyncHttpClientRequestCustomizer should override this; tests relying on it are + // skipped otherwise. + protected McpClientTransport createMcpTransport( + Consumer> requestCustomizers) { + return null; + } + protected Duration getRequestTimeout() { return Duration.ofSeconds(14); } @@ -126,6 +137,30 @@ void verifyCallSucceedsWithImplicitInitialization(Function(); + Function recording = name -> (builder, method, endpoint, body, + context) -> { + invocations.add(name); + return Mono.just(builder); + }; + + var transport = createMcpTransport(customizers -> { + customizers.add(recording.apply("second")); + customizers.add(recording.apply("third")); + customizers.add(0, recording.apply("first")); + }); + assumeTrue(transport != null, "Transport does not support request customizers"); + + withClient(transport, mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); + // Later requests may run concurrently, e.g. the Streamable HTTP GET + // stream, so only check the first one. + assertThat(invocations).startsWith("first", "second", "third"); + }); + } + @Test void testConstructorWithInvalidArguments() { assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java index a29ca16db..521f4397e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java @@ -4,7 +4,11 @@ package io.modelcontextprotocol.client; +import java.util.List; +import java.util.function.Consumer; + import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.spec.McpClientTransport; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -29,6 +33,12 @@ protected McpClientTransport createMcpTransport() { return HttpClientStreamableHttpTransport.builder(host).build(); } + @Override + protected McpClientTransport createMcpTransport( + Consumer> requestCustomizers) { + return HttpClientStreamableHttpTransport.builder(host).asyncHttpRequestCustomizers(requestCustomizers).build(); + } + @BeforeAll static void startContainer() { container.start(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java index ee5e5de05..13c9cf221 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java @@ -41,7 +41,7 @@ public class HttpClientStreamableHttpSyncClientTests extends AbstractMcpSyncClie @Override protected McpClientTransport createMcpTransport() { - return HttpClientStreamableHttpTransport.builder(host).httpRequestCustomizer(requestCustomizer).build(); + return HttpClientStreamableHttpTransport.builder(host).addHttpRequestCustomizer(requestCustomizer).build(); } @BeforeAll diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java index 91a8b6c82..f6ada1503 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java @@ -4,6 +4,9 @@ package io.modelcontextprotocol.client; +import java.util.List; +import java.util.function.Consumer; + import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Timeout; @@ -11,6 +14,7 @@ import org.testcontainers.containers.wait.strategy.Wait; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.spec.McpClientTransport; /** @@ -35,6 +39,12 @@ protected McpClientTransport createMcpTransport() { return HttpClientSseClientTransport.builder(host).build(); } + @Override + protected McpClientTransport createMcpTransport( + Consumer> requestCustomizers) { + return HttpClientSseClientTransport.builder(host).asyncHttpRequestCustomizers(requestCustomizers).build(); + } + @BeforeAll static void startContainer() { container.start(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java index d903b3b3c..90860892d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java @@ -47,7 +47,7 @@ class HttpSseMcpSyncClientTests extends AbstractMcpSyncClientTests { @Override protected McpClientTransport createMcpTransport() { - return HttpClientSseClientTransport.builder(host).httpRequestCustomizer(requestCustomizer).build(); + return HttpClientSseClientTransport.builder(host).addHttpRequestCustomizer(requestCustomizer).build(); } @BeforeAll diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index 8735aaaf1..35b13815e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -38,13 +38,16 @@ import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.matches; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -335,7 +338,7 @@ void testRequestCustomizer() { // Create a transport with the customizer var customizedTransport = HttpClientSseClientTransport.builder(host) - .httpRequestCustomizer(mockCustomizer) + .addHttpRequestCustomizer(mockCustomizer) .build(); // Connect @@ -377,7 +380,7 @@ void testAsyncRequestCustomizer() { // Create a transport with the customizer var customizedTransport = HttpClientSseClientTransport.builder(host) - .asyncHttpRequestCustomizer(mockCustomizer) + .addAsyncHttpRequestCustomizer(mockCustomizer) .build(); // Connect @@ -411,6 +414,74 @@ void testAsyncRequestCustomizer() { customizedTransport.closeGracefully().block(); } + @Test + void testRequestCustomizersAreAppliedInOrder() { + var first = mock(McpSyncHttpClientRequestCustomizer.class); + var second = mock(McpAsyncHttpClientRequestCustomizer.class); + when(second.customize(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); + var third = mock(McpSyncHttpClientRequestCustomizer.class); + + var customizedTransport = HttpClientSseClientTransport.builder(host) + .addHttpRequestCustomizer(first) + .addAsyncHttpRequestCustomizer(second) + .addHttpRequestCustomizer(third) + .build(); + + StepVerifier.create(customizedTransport.connect(Function.identity())).verifyComplete(); + + var inOrder = inOrder(first, second, third); + inOrder.verify(first).customize(any(), eq("GET"), any(), isNull(), any()); + inOrder.verify(second).customize(any(), eq("GET"), any(), isNull(), any()); + inOrder.verify(third).customize(any(), eq("GET"), any(), isNull(), any()); + + customizedTransport.closeGracefully().block(); + } + + @Test + @SuppressWarnings("deprecation") + void testRequestCustomizerSettersReplacePreviousCustomizers() { + var replacedSync = mock(McpSyncHttpClientRequestCustomizer.class); + var replacedAsync = mock(McpAsyncHttpClientRequestCustomizer.class); + var syncCustomizer = mock(McpSyncHttpClientRequestCustomizer.class); + var asyncCustomizer = mock(McpAsyncHttpClientRequestCustomizer.class); + when(asyncCustomizer.customize(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); + + var syncTransport = HttpClientSseClientTransport.builder(host) + .addHttpRequestCustomizer(replacedSync) + .addAsyncHttpRequestCustomizer(replacedAsync) + .httpRequestCustomizer(syncCustomizer) + .build(); + var asyncTransport = HttpClientSseClientTransport.builder(host) + .addHttpRequestCustomizer(replacedSync) + .addAsyncHttpRequestCustomizer(replacedAsync) + .asyncHttpRequestCustomizer(asyncCustomizer) + .build(); + + StepVerifier.create(syncTransport.connect(Function.identity())).verifyComplete(); + StepVerifier.create(asyncTransport.connect(Function.identity())).verifyComplete(); + + verify(syncCustomizer).customize(any(), eq("GET"), any(), isNull(), any()); + verify(asyncCustomizer).customize(any(), eq("GET"), any(), isNull(), any()); + verifyNoInteractions(replacedSync, replacedAsync); + + syncTransport.closeGracefully().block(); + asyncTransport.closeGracefully().block(); + } + + @Test + void testNullRequestCustomizerIsRejected() { + var builder = HttpClientSseClientTransport.builder(host); + + assertThatIllegalArgumentException().isThrownBy(() -> builder.addHttpRequestCustomizer(null)); + assertThatIllegalArgumentException().isThrownBy(() -> builder.addAsyncHttpRequestCustomizer(null)); + + builder.asyncHttpRequestCustomizers(customizers -> customizers.add(null)); + assertThatIllegalArgumentException().isThrownBy(builder::build) + .withMessage("httpRequestCustomizers must not contain null elements"); + } + @Test void testMessageEndpointValidation() throws InvalidSseMessageEndpointException { var uriCaptor = ArgumentCaptor.forClass(URI.class); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyResponseTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyResponseTests.java index 5faf3ae4e..fb2e8b49f 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyResponseTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyResponseTests.java @@ -108,7 +108,7 @@ void testNotificationInitialized() throws URISyntaxException { var uri = new URI(host + "/mcp"); var mockRequestCustomizer = mock(McpSyncHttpClientRequestCustomizer.class); var transport = HttpClientStreamableHttpTransport.builder(host) - .httpRequestCustomizer(mockRequestCustomizer) + .addHttpRequestCustomizer(mockRequestCustomizer) .build(); // Some servers answer a notification with an empty JSON body rather than 202. diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index 002bf5f6d..ad14c70a1 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -24,11 +24,14 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -77,7 +80,7 @@ void testRequestCustomizer() throws URISyntaxException { var mockRequestCustomizer = mock(McpSyncHttpClientRequestCustomizer.class); var transport = HttpClientStreamableHttpTransport.builder(host) - .httpRequestCustomizer(mockRequestCustomizer) + .addHttpRequestCustomizer(mockRequestCustomizer) .build(); withTransport(transport, (t) -> { @@ -107,7 +110,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException { .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); var transport = HttpClientStreamableHttpTransport.builder(host) - .asyncHttpRequestCustomizer(mockRequestCustomizer) + .addAsyncHttpRequestCustomizer(mockRequestCustomizer) .build(); withTransport(transport, (t) -> { @@ -129,6 +132,79 @@ void testAsyncRequestCustomizer() throws URISyntaxException { }); } + @Test + void testRequestCustomizersAreAppliedInOrder() { + var first = mock(McpSyncHttpClientRequestCustomizer.class); + var second = mock(McpAsyncHttpClientRequestCustomizer.class); + when(second.customize(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); + var third = mock(McpSyncHttpClientRequestCustomizer.class); + + var transport = HttpClientStreamableHttpTransport.builder(host) + .addHttpRequestCustomizer(first) + .addAsyncHttpRequestCustomizer(second) + .addHttpRequestCustomizer(third) + .build(); + + withTransport(transport, (t) -> { + StepVerifier.create(t.sendMessage(initializeMessage())).verifyComplete(); + + var inOrder = inOrder(first, second, third); + inOrder.verify(first).customize(any(), eq("POST"), any(), any(), any()); + inOrder.verify(second).customize(any(), eq("POST"), any(), any(), any()); + inOrder.verify(third).customize(any(), eq("POST"), any(), any(), any()); + }); + } + + @Test + @SuppressWarnings("deprecation") + void testRequestCustomizerSettersReplacePreviousCustomizers() { + var replacedSync = mock(McpSyncHttpClientRequestCustomizer.class); + var replacedAsync = mock(McpAsyncHttpClientRequestCustomizer.class); + var syncCustomizer = mock(McpSyncHttpClientRequestCustomizer.class); + var asyncCustomizer = mock(McpAsyncHttpClientRequestCustomizer.class); + when(asyncCustomizer.customize(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); + + var syncTransport = HttpClientStreamableHttpTransport.builder(host) + .addHttpRequestCustomizer(replacedSync) + .addAsyncHttpRequestCustomizer(replacedAsync) + .httpRequestCustomizer(syncCustomizer) + .build(); + var asyncTransport = HttpClientStreamableHttpTransport.builder(host) + .addHttpRequestCustomizer(replacedSync) + .addAsyncHttpRequestCustomizer(replacedAsync) + .asyncHttpRequestCustomizer(asyncCustomizer) + .build(); + + withTransport(syncTransport, (t) -> StepVerifier.create(t.sendMessage(initializeMessage())).verifyComplete()); + withTransport(asyncTransport, (t) -> StepVerifier.create(t.sendMessage(initializeMessage())).verifyComplete()); + + verify(syncCustomizer).customize(any(), eq("POST"), any(), any(), any()); + verify(asyncCustomizer).customize(any(), eq("POST"), any(), any(), any()); + verifyNoInteractions(replacedSync, replacedAsync); + } + + @Test + void testNullRequestCustomizerIsRejected() { + var builder = HttpClientStreamableHttpTransport.builder(host); + + assertThatIllegalArgumentException().isThrownBy(() -> builder.addHttpRequestCustomizer(null)); + assertThatIllegalArgumentException().isThrownBy(() -> builder.addAsyncHttpRequestCustomizer(null)); + + builder.asyncHttpRequestCustomizers(customizers -> customizers.add(null)); + assertThatIllegalArgumentException().isThrownBy(builder::build) + .withMessage("httpRequestCustomizers must not contain null elements"); + } + + private static McpSchema.JSONRPCRequest initializeMessage() { + var initializeRequest = McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().roots(true).build(), + McpSchema.Implementation.builder("MCP Client", "0.3.1").build()) + .build(); + return new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); + } + @Test void testCloseUninitialized() { var transport = HttpClientStreamableHttpTransport.builder(host).build(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java index 6979e0983..7460de65a 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java @@ -115,13 +115,13 @@ public class AsyncServerMcpTransportContextIntegrationTests { private final McpAsyncClient asyncStreamableClient = McpClient .async(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .asyncHttpRequestCustomizer(asyncClientRequestCustomizer) + .addAsyncHttpRequestCustomizer(asyncClientRequestCustomizer) .build()) .build(); private final McpAsyncClient asyncSseClient = McpClient .async(HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .asyncHttpRequestCustomizer(asyncClientRequestCustomizer) + .addAsyncHttpRequestCustomizer(asyncClientRequestCustomizer) .build()) .build(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java index 876f6c44d..6371d7d11 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java @@ -104,14 +104,14 @@ public class SyncServerMcpTransportContextIntegrationTests { private final McpSyncClient streamableClient = McpClient .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .httpRequestCustomizer(clientRequestCustomizer) + .addHttpRequestCustomizer(clientRequestCustomizer) .build()) .transportContextProvider(clientContextProvider) .build(); private final McpSyncClient sseClient = McpClient .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .httpRequestCustomizer(clientRequestCustomizer) + .addHttpRequestCustomizer(clientRequestCustomizer) .build()) .transportContextProvider(clientContextProvider) .build(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java index a4cbdee44..14b2c0f48 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java @@ -78,7 +78,7 @@ static void createTransportAndStartTomcat(ServerFactory serverFactory) { void setUp() { var clientTransport = HttpClientStreamableHttpTransport.builder(baseUrl) .jsonMapper(McpJsonDefaults.getMapper()) - .httpRequestCustomizer((builder, method, endpoint, body, context) -> requestCustomizer.customize(builder, + .addHttpRequestCustomizer((builder, method, endpoint, body, context) -> requestCustomizer.customize(builder, method, endpoint, body, context)) .openConnectionOnStartup(true) .build(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java index fefce73ef..d5141f229 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -223,7 +223,7 @@ public Sse() { @Override public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientSseClientTransport.builder(baseUrl) - .httpRequestCustomizer(requestCustomizer) + .addHttpRequestCustomizer(requestCustomizer) .jsonMapper(McpJsonDefaults.getMapper()) .build(); return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); @@ -256,7 +256,7 @@ public StreamableHttp() { @Override public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) - .httpRequestCustomizer(requestCustomizer) + .addHttpRequestCustomizer(requestCustomizer) .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build(); @@ -290,7 +290,7 @@ public Stateless() { @Override public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { var transport = HttpClientStreamableHttpTransport.builder(baseUrl) - .httpRequestCustomizer(requestCustomizer) + .addHttpRequestCustomizer(requestCustomizer) .jsonMapper(McpJsonDefaults.getMapper()) .openConnectionOnStartup(true) .build();