diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfiguration.java index 6bfdee7dc3f..79aabc0d2cc 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfiguration.java @@ -94,8 +94,14 @@ public List mcpHttpClientTransports(McpSseClientPropert for (Map.Entry serverParameters : sseProperties.getConnections().entrySet()) { - var transport = new HttpClientSseClientTransport(HttpClient.newBuilder(), serverParameters.getValue().url(), - objectMapper); + String baseUrl = serverParameters.getValue().url(); + String sseEndpoint = serverParameters.getValue().sseEndpoint() != null + ? serverParameters.getValue().sseEndpoint() : "/sse"; + var transport = HttpClientSseClientTransport.builder(baseUrl) + .sseEndpoint(sseEndpoint) + .clientBuilder(HttpClient.newBuilder()) + .objectMapper(objectMapper) + .build(); sseTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport)); } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfiguration.java index 4fc62bf68ef..4c064835e3b 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfiguration.java @@ -90,7 +90,12 @@ public List webFluxClientTransports(McpSseClientPropert for (Map.Entry serverParameters : sseProperties.getConnections().entrySet()) { var webClientBuilder = webClientBuilderTemplate.clone().baseUrl(serverParameters.getValue().url()); - var transport = new WebFluxSseClientTransport(webClientBuilder, objectMapper); + String sseEndpoint = serverParameters.getValue().sseEndpoint() != null + ? serverParameters.getValue().sseEndpoint() : "/sse"; + var transport = WebFluxSseClientTransport.builder(webClientBuilder) + .sseEndpoint(sseEndpoint) + .objectMapper(objectMapper) + .build(); sseTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport)); } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientProperties.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientProperties.java index 7abec1cae5e..54a1963d0d6 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientProperties.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientProperties.java @@ -67,8 +67,9 @@ public Map getConnections() { * Parameters for configuring an SSE connection to an MCP server. * * @param url the URL endpoint for SSE communication with the MCP server + * @param sseEndpoint the SSE endpoint for the MCP server */ - public record SseParameters(String url) { + public record SseParameters(String url, String sseEndpoint) { } } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationTests.java index 406502070b7..fadf71cec75 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationTests.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationTests.java @@ -16,14 +16,27 @@ package org.springframework.ai.mcp.client.autoconfigure; +import java.lang.reflect.Field; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; +/** + * Tests for {@link SseHttpClientTransportAutoConfiguration}. + * + * @author Christian Tzolov + */ public class SseHttpClientTransportAutoConfigurationTests { private final ApplicationContextRunner applicationContext = new ApplicationContextRunner() @@ -31,13 +44,11 @@ public class SseHttpClientTransportAutoConfigurationTests { @Test void mcpHttpClientTransportsNotPresentIfMissingWebFluxSseClientTransportPresent() { - this.applicationContext.run(context -> assertThat(context.containsBean("mcpHttpClientTransports")).isFalse()); } @Test void mcpHttpClientTransportsPresentIfMissingWebFluxSseClientTransportNotPresent() { - this.applicationContext .withClassLoader( new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) @@ -46,7 +57,6 @@ void mcpHttpClientTransportsPresentIfMissingWebFluxSseClientTransportNotPresent( @Test void mcpHttpClientTransportsNotPresentIfMcpClientDisabled() { - this.applicationContext .withClassLoader( new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) @@ -54,4 +64,140 @@ void mcpHttpClientTransportsNotPresentIfMcpClientDisabled() { .run(context -> assertThat(context.containsBean("mcpHttpClientTransports")).isFalse()); } + @Test + void noTransportsCreatedWithEmptyConnections() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .run(context -> { + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).isEmpty(); + }); + } + + @Test + void singleConnectionCreatesOneTransport() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).hasSize(1); + assertThat(transports.get(0).name()).isEqualTo("server1"); + assertThat(transports.get(0).transport()).isInstanceOf(HttpClientSseClientTransport.class); + }); + } + + @Test + void multipleConnectionsCreateMultipleTransports() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081") + .run(context -> { + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).hasSize(2); + assertThat(transports).extracting("name").containsExactlyInAnyOrder("server1", "server2"); + assertThat(transports).extracting("transport") + .allMatch(transport -> transport instanceof HttpClientSseClientTransport); + for (NamedClientMcpTransport transport : transports) { + assertThat(transport.transport()).isInstanceOf(HttpClientSseClientTransport.class); + assertThat(getSseEndpoint((HttpClientSseClientTransport) transport.transport())).isEqualTo("/sse"); + } + }); + } + + @Test + void customSseEndpointIsRespected() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/custom-sse") + .run(context -> { + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).hasSize(1); + assertThat(transports.get(0).name()).isEqualTo("server1"); + assertThat(transports.get(0).transport()).isInstanceOf(HttpClientSseClientTransport.class); + + assertThat(getSseEndpoint((HttpClientSseClientTransport) transports.get(0).transport())) + .isEqualTo("/custom-sse"); + }); + } + + @Test + void customObjectMapperIsUsed() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .withUserConfiguration(CustomObjectMapperConfiguration.class) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + assertThat(context.getBean(ObjectMapper.class)).isNotNull(); + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).hasSize(1); + }); + } + + @Test + void defaultSseEndpointIsUsedWhenNotSpecified() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).hasSize(1); + assertThat(transports.get(0).name()).isEqualTo("server1"); + assertThat(transports.get(0).transport()).isInstanceOf(HttpClientSseClientTransport.class); + // Default SSE endpoint is "/sse" as specified in the configuration class + }); + } + + @Test + void mixedConnectionsWithAndWithoutCustomSseEndpoint() { + this.applicationContext + .withClassLoader( + new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/custom-sse", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081") + .run(context -> { + List transports = context.getBean("mcpHttpClientTransports", List.class); + assertThat(transports).hasSize(2); + assertThat(transports).extracting("name").containsExactlyInAnyOrder("server1", "server2"); + assertThat(transports).extracting("transport") + .allMatch(transport -> transport instanceof HttpClientSseClientTransport); + for (NamedClientMcpTransport transport : transports) { + assertThat(transport.transport()).isInstanceOf(HttpClientSseClientTransport.class); + if (transport.name().equals("server1")) { + assertThat(getSseEndpoint((HttpClientSseClientTransport) transport.transport())) + .isEqualTo("/custom-sse"); + } + else { + assertThat(getSseEndpoint((HttpClientSseClientTransport) transport.transport())) + .isEqualTo("/sse"); + } + } + }); + } + + private String getSseEndpoint(HttpClientSseClientTransport transport) { + Field privateField = ReflectionUtils.findField(HttpClientSseClientTransport.class, "sseEndpoint"); + ReflectionUtils.makeAccessible(privateField); + return (String) ReflectionUtils.getField(privateField, transport); + } + + @Configuration + static class CustomObjectMapperConfiguration { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfigurationTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfigurationTests.java index aad5272f0e0..e1faef952b0 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfigurationTests.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfigurationTests.java @@ -16,14 +16,28 @@ package org.springframework.ai.mcp.client.autoconfigure; +import java.lang.reflect.Field; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; +/** + * Tests for {@link SseWebFluxTransportAutoConfiguration}. + * + * @author Christian Tzolov + */ public class SseWebFluxTransportAutoConfigurationTests { private final ApplicationContextRunner applicationContext = new ApplicationContextRunner() @@ -31,13 +45,11 @@ public class SseWebFluxTransportAutoConfigurationTests { @Test void webFluxClientTransportsPresentIfWebFluxSseClientTransportPresent() { - this.applicationContext.run(context -> assertThat(context.containsBean("webFluxClientTransports")).isTrue()); } @Test void webFluxClientTransportsNotPresentIfMissingWebFluxSseClientTransportNotPresent() { - this.applicationContext .withClassLoader( new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport")) @@ -46,9 +58,148 @@ void webFluxClientTransportsNotPresentIfMissingWebFluxSseClientTransportNotPrese @Test void webFluxClientTransportsNotPresentIfMcpClientDisabled() { - this.applicationContext.withPropertyValues("spring.ai.mcp.client.enabled", "false") .run(context -> assertThat(context.containsBean("webFluxClientTransports")).isFalse()); } + @Test + void noTransportsCreatedWithEmptyConnections() { + this.applicationContext.run(context -> { + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).isEmpty(); + }); + } + + @Test + void singleConnectionCreatesOneTransport() { + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(1); + assertThat(transports.get(0).name()).isEqualTo("server1"); + assertThat(transports.get(0).transport()).isInstanceOf(WebFluxSseClientTransport.class); + }); + } + + @Test + void multipleConnectionsCreateMultipleTransports() { + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081") + .run(context -> { + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(2); + assertThat(transports).extracting("name").containsExactlyInAnyOrder("server1", "server2"); + assertThat(transports).extracting("transport") + .allMatch(transport -> transport instanceof WebFluxSseClientTransport); + for (NamedClientMcpTransport transport : transports) { + assertThat(transport.transport()).isInstanceOf(WebFluxSseClientTransport.class); + assertThat(getSseEndpoint((WebFluxSseClientTransport) transport.transport())).isEqualTo("/sse"); + } + }); + } + + @Test + void customSseEndpointIsRespected() { + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/custom-sse") + .run(context -> { + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(1); + assertThat(transports.get(0).name()).isEqualTo("server1"); + assertThat(transports.get(0).transport()).isInstanceOf(WebFluxSseClientTransport.class); + + assertThat(getSseEndpoint((WebFluxSseClientTransport) transports.get(0).transport())) + .isEqualTo("/custom-sse"); + }); + } + + @Test + void customWebClientBuilderIsUsed() { + this.applicationContext.withUserConfiguration(CustomWebClientConfiguration.class) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + assertThat(context.getBean(WebClient.Builder.class)).isNotNull(); + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(1); + }); + } + + @Test + void customObjectMapperIsUsed() { + this.applicationContext.withUserConfiguration(CustomObjectMapperConfiguration.class) + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + assertThat(context.getBean(ObjectMapper.class)).isNotNull(); + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(1); + }); + } + + @Test + void defaultSseEndpointIsUsedWhenNotSpecified() { + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080") + .run(context -> { + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(1); + assertThat(transports.get(0).name()).isEqualTo("server1"); + assertThat(transports.get(0).transport()).isInstanceOf(WebFluxSseClientTransport.class); + // Default SSE endpoint is "/sse" as specified in the configuration class + }); + } + + @Test + void mixedConnectionsWithAndWithoutCustomSseEndpoint() { + this.applicationContext + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/custom-sse", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081") + .run(context -> { + List transports = context.getBean("webFluxClientTransports", List.class); + assertThat(transports).hasSize(2); + assertThat(transports).extracting("name").containsExactlyInAnyOrder("server1", "server2"); + assertThat(transports).extracting("transport") + .allMatch(transport -> transport instanceof WebFluxSseClientTransport); + for (NamedClientMcpTransport transport : transports) { + assertThat(transport.transport()).isInstanceOf(WebFluxSseClientTransport.class); + if (transport.name().equals("server1")) { + assertThat(getSseEndpoint((WebFluxSseClientTransport) transport.transport())) + .isEqualTo("/custom-sse"); + } + else { + assertThat(getSseEndpoint((WebFluxSseClientTransport) transport.transport())).isEqualTo("/sse"); + } + } + }); + } + + private String getSseEndpoint(WebFluxSseClientTransport transport) { + Field privateField = ReflectionUtils.findField(WebFluxSseClientTransport.class, "sseEndpoint"); + ReflectionUtils.makeAccessible(privateField); + return (String) ReflectionUtils.getField(privateField, transport); + } + + @Configuration + static class CustomWebClientConfiguration { + + @Bean + WebClient.Builder webClientBuilder() { + return WebClient.builder().baseUrl("http://custom-base-url"); + } + + } + + @Configuration + static class CustomObjectMapperConfiguration { + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + } diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpClientCommonPropertiesTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpClientCommonPropertiesTests.java new file mode 100644 index 00000000000..49d4c5dba8e --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpClientCommonPropertiesTests.java @@ -0,0 +1,289 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.client.autoconfigure.properties; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link McpClientCommonProperties}. + * + * @author Christian Tzolov + */ +class McpClientCommonPropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfiguration.class); + + @Test + void defaultValues() { + this.contextRunner.run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.isInitialized()).isTrue(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC); + assertThat(properties.isRootChangeNotification()).isTrue(); + }); + } + + @Test + void customValues() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=false", "spring.ai.mcp.client.name=custom-client", + "spring.ai.mcp.client.version=2.0.0", "spring.ai.mcp.client.initialized=false", + "spring.ai.mcp.client.request-timeout=30s", "spring.ai.mcp.client.type=ASYNC", + "spring.ai.mcp.client.root-change-notification=false") + .run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isEnabled()).isFalse(); + assertThat(properties.getName()).isEqualTo("custom-client"); + assertThat(properties.getVersion()).isEqualTo("2.0.0"); + assertThat(properties.isInitialized()).isFalse(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.ASYNC); + assertThat(properties.isRootChangeNotification()).isFalse(); + }); + } + + @Test + void setterGetterMethods() { + McpClientCommonProperties properties = new McpClientCommonProperties(); + + // Test enabled property + properties.setEnabled(false); + assertThat(properties.isEnabled()).isFalse(); + + // Test name property + properties.setName("test-client"); + assertThat(properties.getName()).isEqualTo("test-client"); + + // Test version property + properties.setVersion("3.0.0"); + assertThat(properties.getVersion()).isEqualTo("3.0.0"); + + // Test initialized property + properties.setInitialized(false); + assertThat(properties.isInitialized()).isFalse(); + + // Test requestTimeout property + Duration timeout = Duration.ofMinutes(5); + properties.setRequestTimeout(timeout); + assertThat(properties.getRequestTimeout()).isEqualTo(timeout); + + // Test type property + properties.setType(McpClientCommonProperties.ClientType.ASYNC); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.ASYNC); + + // Test rootChangeNotification property + properties.setRootChangeNotification(false); + assertThat(properties.isRootChangeNotification()).isFalse(); + } + + @Test + void durationPropertyBinding() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.request-timeout=PT1M30S").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(90)); + }); + } + + @Test + void enumPropertyBinding() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.type=ASYNC").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.ASYNC); + }); + } + + @Test + void propertiesFileBinding() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=false", "spring.ai.mcp.client.name=test-mcp-client", + "spring.ai.mcp.client.version=0.5.0", "spring.ai.mcp.client.initialized=false", + "spring.ai.mcp.client.request-timeout=45s", "spring.ai.mcp.client.type=ASYNC", + "spring.ai.mcp.client.root-change-notification=false") + .run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isEnabled()).isFalse(); + assertThat(properties.getName()).isEqualTo("test-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("0.5.0"); + assertThat(properties.isInitialized()).isFalse(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(45)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.ASYNC); + assertThat(properties.isRootChangeNotification()).isFalse(); + }); + } + + @Test + void invalidEnumValue() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.type=INVALID_TYPE").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).hasRootCauseInstanceOf(IllegalArgumentException.class); + // The error message doesn't contain the exact enum value, so we'll check for + // a more general message + assertThat(context.getStartupFailure().getMessage()).contains("Could not bind properties"); + }); + } + + @Test + void invalidDurationFormat() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.request-timeout=invalid-duration").run(context -> { + assertThat(context).hasFailed(); + // The error message doesn't contain the property name, so we'll check for a + // more general message + assertThat(context.getStartupFailure().getMessage()).contains("Could not bind properties"); + }); + } + + @Test + void yamlConfigurationBinding() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.enabled=false", "spring.ai.mcp.client.name=test-mcp-client-yaml", + "spring.ai.mcp.client.version=0.6.0", "spring.ai.mcp.client.initialized=false", + "spring.ai.mcp.client.request-timeout=60s", "spring.ai.mcp.client.type=ASYNC", + "spring.ai.mcp.client.root-change-notification=false") + .run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isEnabled()).isFalse(); + assertThat(properties.getName()).isEqualTo("test-mcp-client-yaml"); + assertThat(properties.getVersion()).isEqualTo("0.6.0"); + assertThat(properties.isInitialized()).isFalse(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(60)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.ASYNC); + assertThat(properties.isRootChangeNotification()).isFalse(); + }); + } + + @Test + void configPrefixConstant() { + assertThat(McpClientCommonProperties.CONFIG_PREFIX).isEqualTo("spring.ai.mcp.client"); + } + + @Test + void clientTypeEnumValues() { + assertThat(McpClientCommonProperties.ClientType.values()) + .containsExactly(McpClientCommonProperties.ClientType.SYNC, McpClientCommonProperties.ClientType.ASYNC); + } + + @Test + void disabledProperties() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.enabled=false").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isEnabled()).isFalse(); + // Other properties should still have their default values + assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.isInitialized()).isTrue(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC); + assertThat(properties.isRootChangeNotification()).isTrue(); + }); + } + + @Test + void notInitializedProperties() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.initialized=false").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isInitialized()).isFalse(); + // Other properties should still have their default values + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC); + assertThat(properties.isRootChangeNotification()).isTrue(); + }); + } + + @Test + void rootChangeNotificationDisabled() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.root-change-notification=false").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.isRootChangeNotification()).isFalse(); + // Other properties should still have their default values + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.isInitialized()).isTrue(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC); + }); + } + + @Test + void customRequestTimeout() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.request-timeout=120s").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(120)); + // Other properties should still have their default values + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.isInitialized()).isTrue(); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC); + assertThat(properties.isRootChangeNotification()).isTrue(); + }); + } + + @Test + void asyncClientType() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.type=ASYNC").run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.ASYNC); + // Other properties should still have their default values + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getName()).isEqualTo("spring-ai-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.isInitialized()).isTrue(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(properties.isRootChangeNotification()).isTrue(); + }); + } + + @Test + void customNameAndVersion() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.name=custom-mcp-client", "spring.ai.mcp.client.version=2.5.0") + .run(context -> { + McpClientCommonProperties properties = context.getBean(McpClientCommonProperties.class); + assertThat(properties.getName()).isEqualTo("custom-mcp-client"); + assertThat(properties.getVersion()).isEqualTo("2.5.0"); + // Other properties should still have their default values + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.isInitialized()).isTrue(); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(properties.getType()).isEqualTo(McpClientCommonProperties.ClientType.SYNC); + assertThat(properties.isRootChangeNotification()).isTrue(); + }); + } + + @Configuration + @EnableConfigurationProperties(McpClientCommonProperties.class) + static class TestConfiguration { + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientPropertiesTests.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientPropertiesTests.java new file mode 100644 index 00000000000..335ed26b3e1 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientPropertiesTests.java @@ -0,0 +1,291 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.client.autoconfigure.properties; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link McpSseClientProperties}. + * + * @author Christian Tzolov + */ +class McpSseClientPropertiesTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfiguration.class); + + @Test + void defaultValues() { + this.contextRunner.run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).isNotNull(); + assertThat(properties.getConnections()).isEmpty(); + }); + } + + @Test + void singleConnection() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080/events") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server1"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080/events"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isNull(); + }); + } + + @Test + void multipleConnections() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080/events", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081/events") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(2); + assertThat(properties.getConnections()).containsKeys("server1", "server2"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080/events"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isNull(); + assertThat(properties.getConnections().get("server2").url()) + .isEqualTo("http://otherserver:8081/events"); + assertThat(properties.getConnections().get("server2").sseEndpoint()).isNull(); + }); + } + + @Test + void connectionWithEmptyUrl() { + this.contextRunner.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=").run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server1"); + assertThat(properties.getConnections().get("server1").url()).isEmpty(); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isNull(); + }); + } + + @Test + void connectionWithNullUrl() { + // This test verifies that a null URL is not allowed in the SseParameters record + // Since records require all parameters to be provided, this test is more of a + // documentation + // of expected behavior rather than a functional test + McpSseClientProperties properties = new McpSseClientProperties(); + Map connections = properties.getConnections(); + + // We can't create an SseParameters with null URL due to record constraints + // But we can verify that the connections map is initialized and empty + assertThat(connections).isNotNull(); + assertThat(connections).isEmpty(); + } + + @Test + void sseParametersRecord() { + String url = "http://test-server:8080/events"; + String sseUrl = "/sse"; + McpSseClientProperties.SseParameters params = new McpSseClientProperties.SseParameters(url, sseUrl); + + assertThat(params.url()).isEqualTo(url); + assertThat(params.sseEndpoint()).isEqualTo(sseUrl); + } + + @Test + void sseParametersRecordWithNullSseEdnpoint() { + String url = "http://test-server:8080/events"; + McpSseClientProperties.SseParameters params = new McpSseClientProperties.SseParameters(url, null); + + assertThat(params.url()).isEqualTo(url); + assertThat(params.sseEndpoint()).isNull(); + } + + @Test + void configPrefixConstant() { + assertThat(McpSseClientProperties.CONFIG_PREFIX).isEqualTo("spring.ai.mcp.client.sse"); + } + + @Test + void yamlConfigurationBinding() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080/events", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081/events") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(2); + assertThat(properties.getConnections()).containsKeys("server1", "server2"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080/events"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isNull(); + assertThat(properties.getConnections().get("server2").url()) + .isEqualTo("http://otherserver:8081/events"); + assertThat(properties.getConnections().get("server2").sseEndpoint()).isNull(); + }); + } + + @Test + void connectionMapManipulation() { + this.contextRunner.run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + Map connections = properties.getConnections(); + + // Add a connection + connections.put("server1", + new McpSseClientProperties.SseParameters("http://localhost:8080/events", "/sse")); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080/events"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isEqualTo("/sse"); + + // Add another connection + connections.put("server2", + new McpSseClientProperties.SseParameters("http://otherserver:8081/events", null)); + assertThat(properties.getConnections()).hasSize(2); + assertThat(properties.getConnections().get("server2").url()).isEqualTo("http://otherserver:8081/events"); + assertThat(properties.getConnections().get("server2").sseEndpoint()).isNull(); + + // Replace a connection + connections.put("server1", + new McpSseClientProperties.SseParameters("http://newserver:8082/events", "/events")); + assertThat(properties.getConnections()).hasSize(2); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://newserver:8082/events"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isEqualTo("/events"); + + // Remove a connection + connections.remove("server1"); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server2"); + assertThat(properties.getConnections()).doesNotContainKey("server1"); + }); + } + + @Test + void specialCharactersInUrl() { + this.contextRunner.withPropertyValues( + "spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080/events?param=value&other=123") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections().get("server1").url()) + .isEqualTo("http://localhost:8080/events?param=value&other=123"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isNull(); + }); + } + + @Test + void specialCharactersInConnectionName() { + this.contextRunner + .withPropertyValues( + "spring.ai.mcp.client.sse.connections.server-with-dashes.url=http://localhost:8080/events") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server-with-dashes"); + assertThat(properties.getConnections().get("server-with-dashes").url()) + .isEqualTo("http://localhost:8080/events"); + assertThat(properties.getConnections().get("server-with-dashes").sseEndpoint()).isNull(); + }); + } + + @Test + void connectionWithSseEndpoint() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/events") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server1"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isEqualTo("/events"); + }); + } + + @Test + void multipleConnectionsWithSseEndpoint() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/events", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081", + "spring.ai.mcp.client.sse.connections.server2.sse-endpoint=/sse") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(2); + assertThat(properties.getConnections()).containsKeys("server1", "server2"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isEqualTo("/events"); + assertThat(properties.getConnections().get("server2").url()).isEqualTo("http://otherserver:8081"); + assertThat(properties.getConnections().get("server2").sseEndpoint()).isEqualTo("/sse"); + }); + } + + @Test + void connectionWithEmptySseEndpoint() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server1"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isEmpty(); + }); + } + + @Test + void mixedConnectionsWithAndWithoutSseEndpoint() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/events", + "spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(2); + assertThat(properties.getConnections()).containsKeys("server1", "server2"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080"); + assertThat(properties.getConnections().get("server1").sseEndpoint()).isEqualTo("/events"); + assertThat(properties.getConnections().get("server2").url()).isEqualTo("http://otherserver:8081"); + assertThat(properties.getConnections().get("server2").sseEndpoint()).isNull(); + }); + } + + @Test + void specialCharactersInSseEndpoint() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080", + "spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/events/stream?format=json&timeout=30") + .run(context -> { + McpSseClientProperties properties = context.getBean(McpSseClientProperties.class); + assertThat(properties.getConnections()).hasSize(1); + assertThat(properties.getConnections()).containsKey("server1"); + assertThat(properties.getConnections().get("server1").url()).isEqualTo("http://localhost:8080"); + assertThat(properties.getConnections().get("server1").sseEndpoint()) + .isEqualTo("/events/stream?format=json&timeout=30"); + }); + } + + @Configuration + @EnableConfigurationProperties(McpSseClientProperties.class) + static class TestConfiguration { + + } + +}