feat(mcp): Add configurable SSE endpoint support for MCP client transports

Enhance MCP client transports with configurable SSE endpoint support:

- Add sseEndpoint parameter to SseParameters record
- Update HttpClientSseClientTransport to use builder pattern with sseEndpoint support
- Update WebFluxSseClientTransport to use builder pattern with sseEndpoint support
- Set default SSE endpoint to /sse when not explicitly configured
- Add tests for MCP client properties
- Enhance MCP client transport tests and refactor config

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-04-30 02:03:51 +03:00
parent 781e85dc2d
commit 04249e8257
7 changed files with 899 additions and 10 deletions

View File

@@ -94,8 +94,14 @@ public class SseHttpClientTransportAutoConfiguration {
for (Map.Entry<String, SseParameters> 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));
}

View File

@@ -90,7 +90,12 @@ public class SseWebFluxTransportAutoConfiguration {
for (Map.Entry<String, SseParameters> 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));
}

View File

@@ -67,8 +67,9 @@ public class McpSseClientProperties {
* 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) {
}
}

View File

@@ -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 @@ public class SseHttpClientTransportAutoConfigurationTests {
@Test
void mcpHttpClientTransportsNotPresentIfMcpClientDisabled() {
this.applicationContext
.withClassLoader(
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
@@ -54,4 +64,140 @@ public class SseHttpClientTransportAutoConfigurationTests {
.run(context -> assertThat(context.containsBean("mcpHttpClientTransports")).isFalse());
}
@Test
void noTransportsCreatedWithEmptyConnections() {
this.applicationContext
.withClassLoader(
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
.run(context -> {
List<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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();
}
}
}

View File

@@ -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 @@ public class SseWebFluxTransportAutoConfigurationTests {
@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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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<NamedClientMcpTransport> 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();
}
}
}

View File

@@ -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 {
}
}

View File

@@ -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<String, McpSseClientProperties.SseParameters> 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<String, McpSseClientProperties.SseParameters> 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 {
}
}