Skip to content

Commit 04249e8

Browse files
committed
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>
1 parent 781e85d commit 04249e8

File tree

7 files changed

+899
-10
lines changed

7 files changed

+899
-10
lines changed

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfiguration.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,14 @@ public List<NamedClientMcpTransport> mcpHttpClientTransports(McpSseClientPropert
9494

9595
for (Map.Entry<String, SseParameters> serverParameters : sseProperties.getConnections().entrySet()) {
9696

97-
var transport = new HttpClientSseClientTransport(HttpClient.newBuilder(), serverParameters.getValue().url(),
98-
objectMapper);
97+
String baseUrl = serverParameters.getValue().url();
98+
String sseEndpoint = serverParameters.getValue().sseEndpoint() != null
99+
? serverParameters.getValue().sseEndpoint() : "/sse";
100+
var transport = HttpClientSseClientTransport.builder(baseUrl)
101+
.sseEndpoint(sseEndpoint)
102+
.clientBuilder(HttpClient.newBuilder())
103+
.objectMapper(objectMapper)
104+
.build();
99105
sseTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport));
100106
}
101107

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/SseWebFluxTransportAutoConfiguration.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ public List<NamedClientMcpTransport> webFluxClientTransports(McpSseClientPropert
9090

9191
for (Map.Entry<String, SseParameters> serverParameters : sseProperties.getConnections().entrySet()) {
9292
var webClientBuilder = webClientBuilderTemplate.clone().baseUrl(serverParameters.getValue().url());
93-
var transport = new WebFluxSseClientTransport(webClientBuilder, objectMapper);
93+
String sseEndpoint = serverParameters.getValue().sseEndpoint() != null
94+
? serverParameters.getValue().sseEndpoint() : "/sse";
95+
var transport = WebFluxSseClientTransport.builder(webClientBuilder)
96+
.sseEndpoint(sseEndpoint)
97+
.objectMapper(objectMapper)
98+
.build();
9499
sseTransports.add(new NamedClientMcpTransport(serverParameters.getKey(), transport));
95100
}
96101

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/main/java/org/springframework/ai/mcp/client/autoconfigure/properties/McpSseClientProperties.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ public Map<String, SseParameters> getConnections() {
6767
* Parameters for configuring an SSE connection to an MCP server.
6868
*
6969
* @param url the URL endpoint for SSE communication with the MCP server
70+
* @param sseEndpoint the SSE endpoint for the MCP server
7071
*/
71-
public record SseParameters(String url) {
72+
public record SseParameters(String url, String sseEndpoint) {
7273
}
7374

7475
}

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client/src/test/java/org/springframework/ai/mcp/client/autoconfigure/SseHttpClientTransportAutoConfigurationTests.java

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,39 @@
1616

1717
package org.springframework.ai.mcp.client.autoconfigure;
1818

19+
import java.lang.reflect.Field;
20+
import java.util.List;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
1924
import org.junit.jupiter.api.Test;
2025

2126
import org.springframework.boot.autoconfigure.AutoConfigurations;
2227
import org.springframework.boot.test.context.FilteredClassLoader;
2328
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
29+
import org.springframework.context.annotation.Bean;
30+
import org.springframework.context.annotation.Configuration;
31+
import org.springframework.util.ReflectionUtils;
2432

2533
import static org.assertj.core.api.Assertions.assertThat;
2634

35+
/**
36+
* Tests for {@link SseHttpClientTransportAutoConfiguration}.
37+
*
38+
* @author Christian Tzolov
39+
*/
2740
public class SseHttpClientTransportAutoConfigurationTests {
2841

2942
private final ApplicationContextRunner applicationContext = new ApplicationContextRunner()
3043
.withConfiguration(AutoConfigurations.of(SseHttpClientTransportAutoConfiguration.class));
3144

3245
@Test
3346
void mcpHttpClientTransportsNotPresentIfMissingWebFluxSseClientTransportPresent() {
34-
3547
this.applicationContext.run(context -> assertThat(context.containsBean("mcpHttpClientTransports")).isFalse());
3648
}
3749

3850
@Test
3951
void mcpHttpClientTransportsPresentIfMissingWebFluxSseClientTransportNotPresent() {
40-
4152
this.applicationContext
4253
.withClassLoader(
4354
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
@@ -46,12 +57,147 @@ void mcpHttpClientTransportsPresentIfMissingWebFluxSseClientTransportNotPresent(
4657

4758
@Test
4859
void mcpHttpClientTransportsNotPresentIfMcpClientDisabled() {
49-
5060
this.applicationContext
5161
.withClassLoader(
5262
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
5363
.withPropertyValues("spring.ai.mcp.client.enabled", "false")
5464
.run(context -> assertThat(context.containsBean("mcpHttpClientTransports")).isFalse());
5565
}
5666

67+
@Test
68+
void noTransportsCreatedWithEmptyConnections() {
69+
this.applicationContext
70+
.withClassLoader(
71+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
72+
.run(context -> {
73+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
74+
assertThat(transports).isEmpty();
75+
});
76+
}
77+
78+
@Test
79+
void singleConnectionCreatesOneTransport() {
80+
this.applicationContext
81+
.withClassLoader(
82+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
83+
.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080")
84+
.run(context -> {
85+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
86+
assertThat(transports).hasSize(1);
87+
assertThat(transports.get(0).name()).isEqualTo("server1");
88+
assertThat(transports.get(0).transport()).isInstanceOf(HttpClientSseClientTransport.class);
89+
});
90+
}
91+
92+
@Test
93+
void multipleConnectionsCreateMultipleTransports() {
94+
this.applicationContext
95+
.withClassLoader(
96+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
97+
.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080",
98+
"spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081")
99+
.run(context -> {
100+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
101+
assertThat(transports).hasSize(2);
102+
assertThat(transports).extracting("name").containsExactlyInAnyOrder("server1", "server2");
103+
assertThat(transports).extracting("transport")
104+
.allMatch(transport -> transport instanceof HttpClientSseClientTransport);
105+
for (NamedClientMcpTransport transport : transports) {
106+
assertThat(transport.transport()).isInstanceOf(HttpClientSseClientTransport.class);
107+
assertThat(getSseEndpoint((HttpClientSseClientTransport) transport.transport())).isEqualTo("/sse");
108+
}
109+
});
110+
}
111+
112+
@Test
113+
void customSseEndpointIsRespected() {
114+
this.applicationContext
115+
.withClassLoader(
116+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
117+
.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080",
118+
"spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/custom-sse")
119+
.run(context -> {
120+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
121+
assertThat(transports).hasSize(1);
122+
assertThat(transports.get(0).name()).isEqualTo("server1");
123+
assertThat(transports.get(0).transport()).isInstanceOf(HttpClientSseClientTransport.class);
124+
125+
assertThat(getSseEndpoint((HttpClientSseClientTransport) transports.get(0).transport()))
126+
.isEqualTo("/custom-sse");
127+
});
128+
}
129+
130+
@Test
131+
void customObjectMapperIsUsed() {
132+
this.applicationContext
133+
.withClassLoader(
134+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
135+
.withUserConfiguration(CustomObjectMapperConfiguration.class)
136+
.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080")
137+
.run(context -> {
138+
assertThat(context.getBean(ObjectMapper.class)).isNotNull();
139+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
140+
assertThat(transports).hasSize(1);
141+
});
142+
}
143+
144+
@Test
145+
void defaultSseEndpointIsUsedWhenNotSpecified() {
146+
this.applicationContext
147+
.withClassLoader(
148+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
149+
.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080")
150+
.run(context -> {
151+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
152+
assertThat(transports).hasSize(1);
153+
assertThat(transports.get(0).name()).isEqualTo("server1");
154+
assertThat(transports.get(0).transport()).isInstanceOf(HttpClientSseClientTransport.class);
155+
// Default SSE endpoint is "/sse" as specified in the configuration class
156+
});
157+
}
158+
159+
@Test
160+
void mixedConnectionsWithAndWithoutCustomSseEndpoint() {
161+
this.applicationContext
162+
.withClassLoader(
163+
new FilteredClassLoader("io.modelcontextprotocol.client.transport.WebFluxSseClientTransport"))
164+
.withPropertyValues("spring.ai.mcp.client.sse.connections.server1.url=http://localhost:8080",
165+
"spring.ai.mcp.client.sse.connections.server1.sse-endpoint=/custom-sse",
166+
"spring.ai.mcp.client.sse.connections.server2.url=http://otherserver:8081")
167+
.run(context -> {
168+
List<NamedClientMcpTransport> transports = context.getBean("mcpHttpClientTransports", List.class);
169+
assertThat(transports).hasSize(2);
170+
assertThat(transports).extracting("name").containsExactlyInAnyOrder("server1", "server2");
171+
assertThat(transports).extracting("transport")
172+
.allMatch(transport -> transport instanceof HttpClientSseClientTransport);
173+
for (NamedClientMcpTransport transport : transports) {
174+
assertThat(transport.transport()).isInstanceOf(HttpClientSseClientTransport.class);
175+
if (transport.name().equals("server1")) {
176+
assertThat(getSseEndpoint((HttpClientSseClientTransport) transport.transport()))
177+
.isEqualTo("/custom-sse");
178+
}
179+
else {
180+
assertThat(getSseEndpoint((HttpClientSseClientTransport) transport.transport()))
181+
.isEqualTo("/sse");
182+
}
183+
}
184+
});
185+
}
186+
187+
private String getSseEndpoint(HttpClientSseClientTransport transport) {
188+
Field privateField = ReflectionUtils.findField(HttpClientSseClientTransport.class, "sseEndpoint");
189+
ReflectionUtils.makeAccessible(privateField);
190+
return (String) ReflectionUtils.getField(privateField, transport);
191+
}
192+
193+
@Configuration
194+
static class CustomObjectMapperConfiguration {
195+
196+
@Bean
197+
ObjectMapper objectMapper() {
198+
return new ObjectMapper();
199+
}
200+
201+
}
202+
57203
}

0 commit comments

Comments
 (0)