From 98e3d8fba6d2242c27747e7dea0ad34d21e21d07 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 4 Aug 2025 15:14:21 +0200 Subject: [PATCH] Autoconfigure MCP client with and async HTTP request customizer Signed-off-by: Daniel Garnier-Moiroux --- ...eHttpClientTransportAutoConfiguration.java | 30 ++++++- ...pHttpClientTransportAutoConfiguration.java | 32 ++++++-- ...ttpClientTransportAutoConfigurationIT.java | 78 ++++++++++++++++++- ...ttpClientTransportAutoConfigurationIT.java | 78 ++++++++++++++++++- 4 files changed, 205 insertions(+), 13 deletions(-) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/SseHttpClientTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/SseHttpClientTransportAutoConfiguration.java index 1f713e75e88..6d695a468d7 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/SseHttpClientTransportAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/SseHttpClientTransportAutoConfiguration.java @@ -23,7 +23,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.AsyncHttpRequestCustomizer; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.SyncHttpRequestCustomizer; import io.modelcontextprotocol.spec.McpSchema; import org.springframework.ai.mcp.client.common.autoconfigure.NamedClientMcpTransport; @@ -36,6 +38,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.core.log.LogAccessor; /** * Auto-configuration for Server-Sent Events (SSE) HTTP client transport in the Model @@ -68,6 +71,8 @@ matchIfMissing = true) public class SseHttpClientTransportAutoConfiguration { + private static final LogAccessor logger = new LogAccessor(SseHttpClientTransportAutoConfiguration.class); + /** * Creates a list of HTTP client-based SSE transports for MCP communication. * @@ -77,15 +82,22 @@ public class SseHttpClientTransportAutoConfiguration { *
  • A new HttpClient instance *
  • Server URL from properties *
  • ObjectMapper for JSON processing + *
  • A sync or async HTTP request customizer. Sync takes precedence. * * @param sseProperties the SSE client properties containing server configurations * @param objectMapperProvider the provider for ObjectMapper or a new instance if not * available + * @param syncHttpRequestCustomizer provider for {@link SyncHttpRequestCustomizer} if + * available + * @param asyncHttpRequestCustomizer provider fo {@link AsyncHttpRequestCustomizer} if + * available * @return list of named MCP transports */ @Bean public List sseHttpClientTransports(McpSseClientProperties sseProperties, - ObjectProvider objectMapperProvider) { + ObjectProvider objectMapperProvider, + ObjectProvider syncHttpRequestCustomizer, + ObjectProvider asyncHttpRequestCustomizer) { ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); @@ -96,11 +108,21 @@ public List sseHttpClientTransports(McpSseClientPropert String baseUrl = serverParameters.getValue().url(); String sseEndpoint = serverParameters.getValue().sseEndpoint() != null ? serverParameters.getValue().sseEndpoint() : "/sse"; - var transport = HttpClientSseClientTransport.builder(baseUrl) + HttpClientSseClientTransport.Builder transportBuilder = HttpClientSseClientTransport.builder(baseUrl) .sseEndpoint(sseEndpoint) .clientBuilder(HttpClient.newBuilder()) - .objectMapper(objectMapper) - .build(); + .objectMapper(objectMapper); + + asyncHttpRequestCustomizer.ifUnique(transportBuilder::asyncHttpRequestCustomizer); + syncHttpRequestCustomizer.ifUnique(transportBuilder::httpRequestCustomizer); + if (asyncHttpRequestCustomizer.getIfUnique() != null && syncHttpRequestCustomizer.getIfUnique() != null) { + logger.warn("Found beans of type %s and %s. Using %s.".formatted( + AsyncHttpRequestCustomizer.class.getSimpleName(), + SyncHttpRequestCustomizer.class.getSimpleName(), + SyncHttpRequestCustomizer.class.getSimpleName())); + } + + HttpClientSseClientTransport transport = transportBuilder.build(); sseTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport)); } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/StreamableHttpHttpClientTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/StreamableHttpHttpClientTransportAutoConfiguration.java index f18066553f8..93f07b617fe 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/StreamableHttpHttpClientTransportAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/main/java/org/springframework/ai/mcp/client/httpclient/autoconfigure/StreamableHttpHttpClientTransportAutoConfiguration.java @@ -31,12 +31,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.core.log.LogAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.AsyncHttpRequestCustomizer; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.SyncHttpRequestCustomizer; import io.modelcontextprotocol.spec.McpSchema; /** @@ -59,6 +61,7 @@ * connections *
  • Configures ObjectMapper for JSON serialization/deserialization *
  • Supports multiple named server connections with different URLs + *
  • Adds a sync or async HTTP request customizer. Sync takes precedence. * * * @see HttpClientStreamableHttpTransport @@ -71,6 +74,8 @@ matchIfMissing = true) public class StreamableHttpHttpClientTransportAutoConfiguration { + private static final LogAccessor logger = new LogAccessor(StreamableHttpHttpClientTransportAutoConfiguration.class); + /** * Creates a list of HTTP client-based Streamable HTTP transports for MCP * communication. @@ -86,11 +91,17 @@ public class StreamableHttpHttpClientTransportAutoConfiguration { * configurations * @param objectMapperProvider the provider for ObjectMapper or a new instance if not * available + * @param syncHttpRequestCustomizer provider for {@link SyncHttpRequestCustomizer} if + * available + * @param asyncHttpRequestCustomizer provider fo {@link AsyncHttpRequestCustomizer} if + * available * @return list of named MCP transports */ @Bean public List streamableHttpHttpClientTransports( - McpStreamableHttpClientProperties streamableProperties, ObjectProvider objectMapperProvider) { + McpStreamableHttpClientProperties streamableProperties, ObjectProvider objectMapperProvider, + ObjectProvider syncHttpRequestCustomizer, + ObjectProvider asyncHttpRequestCustomizer) { ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); @@ -103,11 +114,22 @@ public List streamableHttpHttpClientTransports( String streamableHttpEndpoint = serverParameters.getValue().endpoint() != null ? serverParameters.getValue().endpoint() : "/mcp"; - HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(baseUrl) + HttpClientStreamableHttpTransport.Builder transportBuilder = HttpClientStreamableHttpTransport + .builder(baseUrl) .endpoint(streamableHttpEndpoint) .clientBuilder(HttpClient.newBuilder()) - .objectMapper(objectMapper) - .build(); + .objectMapper(objectMapper); + + asyncHttpRequestCustomizer.ifUnique(transportBuilder::asyncHttpRequestCustomizer); + syncHttpRequestCustomizer.ifUnique(transportBuilder::httpRequestCustomizer); + if (asyncHttpRequestCustomizer.getIfUnique() != null && syncHttpRequestCustomizer.getIfUnique() != null) { + logger.warn("Found beans of type %s and %s. Using %s.".formatted( + AsyncHttpRequestCustomizer.class.getSimpleName(), + SyncHttpRequestCustomizer.class.getSimpleName(), + SyncHttpRequestCustomizer.class.getSimpleName())); + } + + HttpClientStreamableHttpTransport transport = transportBuilder.build(); streamableHttpTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport)); } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationIT.java index 70b9b2563a4..a8872f5fe76 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationIT.java @@ -1,10 +1,16 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2025 the original author or authors. */ package org.springframework.ai.mcp.client.autoconfigure; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import java.util.List; @@ -17,12 +23,19 @@ import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration; import org.springframework.ai.mcp.client.httpclient.autoconfigure.SseHttpClientTransportAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.UserConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.AsyncHttpRequestCustomizer; +import io.modelcontextprotocol.client.transport.SyncHttpRequestCustomizer; import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import reactor.core.publisher.Mono; @Timeout(15) public class SseHttpClientTransportAutoConfigurationIT { @@ -79,8 +92,69 @@ void streamableHttpTest() { assertThat(toolsResult.tools()).hasSize(8); logger.info("tools = {}", toolsResult); - }); } + @Test + void usesSyncRequestCustomizer() { + this.contextRunner + .withConfiguration(UserConfigurations.of(SyncRequestCustomizerConfiguration.class, + AsyncRequestCustomizerConfiguration.class)) + .run(context -> { + List mcpClients = (List) context.getBean("mcpSyncClients"); + + assertThat(mcpClients).isNotNull(); + assertThat(mcpClients).hasSize(1); + + McpSyncClient mcpClient = mcpClients.get(0); + + mcpClient.ping(); + + verify(context.getBean(SyncHttpRequestCustomizer.class), atLeastOnce()).customize(any(), any(), any(), + any()); + verifyNoInteractions(context.getBean(AsyncHttpRequestCustomizer.class)); + }); + } + + @Test + void usesAsyncRequestCustomizer() { + this.contextRunner.withConfiguration(UserConfigurations.of(AsyncRequestCustomizerConfiguration.class)) + .run(context -> { + List mcpClients = (List) context.getBean("mcpSyncClients"); + + assertThat(mcpClients).isNotNull(); + assertThat(mcpClients).hasSize(1); + + McpSyncClient mcpClient = mcpClients.get(0); + + mcpClient.ping(); + + verify(context.getBean(AsyncHttpRequestCustomizer.class), atLeastOnce()).customize(any(), any(), any(), + any()); + }); + } + + @Configuration + static class SyncRequestCustomizerConfiguration { + + @Bean + SyncHttpRequestCustomizer syncHttpRequestCustomizer() { + return mock(SyncHttpRequestCustomizer.class); + } + + } + + @Configuration + static class AsyncRequestCustomizerConfiguration { + + @Bean + AsyncHttpRequestCustomizer asyncHttpRequestCustomizer() { + AsyncHttpRequestCustomizer requestCustomizerMock = mock(AsyncHttpRequestCustomizer.class); + when(requestCustomizerMock.customize(any(), any(), any(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); + return requestCustomizerMock; + } + + } + } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/StreamableHttpHttpClientTransportAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/StreamableHttpHttpClientTransportAutoConfigurationIT.java index 452373a711f..36a420272f9 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/StreamableHttpHttpClientTransportAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient/src/test/java/org/springframework/ai/mcp/client/autoconfigure/StreamableHttpHttpClientTransportAutoConfigurationIT.java @@ -1,10 +1,16 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2025 the original author or authors. */ package org.springframework.ai.mcp.client.autoconfigure; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import java.util.List; @@ -17,12 +23,19 @@ import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration; import org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.annotation.UserConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.AsyncHttpRequestCustomizer; +import io.modelcontextprotocol.client.transport.SyncHttpRequestCustomizer; import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import reactor.core.publisher.Mono; @Timeout(15) public class StreamableHttpHttpClientTransportAutoConfigurationIT { @@ -80,8 +93,69 @@ void streamableHttpTest() { assertThat(toolsResult.tools()).hasSize(8); logger.info("tools = {}", toolsResult); - }); } + @Test + void usesSyncRequestCustomizer() { + this.contextRunner + .withConfiguration(UserConfigurations.of(SyncRequestCustomizerConfiguration.class, + AsyncRequestCustomizerConfiguration.class)) + .run(context -> { + List mcpClients = (List) context.getBean("mcpSyncClients"); + + assertThat(mcpClients).isNotNull(); + assertThat(mcpClients).hasSize(1); + + McpSyncClient mcpClient = mcpClients.get(0); + + mcpClient.ping(); + + verify(context.getBean(SyncHttpRequestCustomizer.class), atLeastOnce()).customize(any(), any(), any(), + any()); + verifyNoInteractions(context.getBean(AsyncHttpRequestCustomizer.class)); + }); + } + + @Test + void usesAsyncRequestCustomizer() { + this.contextRunner.withConfiguration(UserConfigurations.of(AsyncRequestCustomizerConfiguration.class)) + .run(context -> { + List mcpClients = (List) context.getBean("mcpSyncClients"); + + assertThat(mcpClients).isNotNull(); + assertThat(mcpClients).hasSize(1); + + McpSyncClient mcpClient = mcpClients.get(0); + + mcpClient.ping(); + + verify(context.getBean(AsyncHttpRequestCustomizer.class), atLeastOnce()).customize(any(), any(), any(), + any()); + }); + } + + @Configuration + static class SyncRequestCustomizerConfiguration { + + @Bean + SyncHttpRequestCustomizer syncHttpRequestCustomizer() { + return mock(SyncHttpRequestCustomizer.class); + } + + } + + @Configuration + static class AsyncRequestCustomizerConfiguration { + + @Bean + AsyncHttpRequestCustomizer asyncHttpRequestCustomizer() { + AsyncHttpRequestCustomizer requestCustomizerMock = mock(AsyncHttpRequestCustomizer.class); + when(requestCustomizerMock.customize(any(), any(), any(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArguments()[0])); + return requestCustomizerMock; + } + + } + }