feat(mcp): customize transport endpoints and improve URI handling (#69)

- Add support for customizable SSE endpoints in HttpClientSseClientTransport
- Replace pathInfo with requestURI in HttpServletSseServerTransportProvider for more reliable endpoint matching
- Implement builder pattern to support the customization options

Related to #40

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
Co-authored-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
or-givati 2025-03-24 14:41:07 +02:00 • committed by Christian Tzolov
commit 2934635d99
5 changed files with 189 additions and 13 deletions

View file

@ -65,11 +65,14 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private static final String ENDPOINT_EVENT_TYPE = "endpoint";
/** Default SSE endpoint path */
private static final String SSE_ENDPOINT = "/sse";
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
/** Base URI for the MCP server */
private final String baseUri;
/** SSE endpoint path */
private final String sseEndpoint;
/** SSE client for handling server-sent events. Uses the /sse endpoint */
private final FlowSseClient sseClient;
@ -110,15 +113,104 @@ public class HttpClientSseClientTransport implements McpClientTransport {
* @throws IllegalArgumentException if objectMapper or clientBuilder is null
*/
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, ObjectMapper objectMapper) {
this(clientBuilder, baseUri, DEFAULT_SSE_ENDPOINT, objectMapper);
}
/**
* Creates a new transport instance with custom HTTP client builder and object mapper.
* @param clientBuilder the HTTP client builder to use
* @param baseUri the base URI of the MCP server
* @param sseEndpoint the SSE endpoint path
* @param objectMapper the object mapper for JSON serialization/deserialization
* @throws IllegalArgumentException if objectMapper or clientBuilder is null
*/
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, String sseEndpoint,
ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.hasText(baseUri, "baseUri must not be empty");
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
Assert.notNull(clientBuilder, "clientBuilder must not be null");
this.baseUri = baseUri;
this.sseEndpoint = sseEndpoint;
this.objectMapper = objectMapper;
this.httpClient = clientBuilder.connectTimeout(Duration.ofSeconds(10)).build();
this.sseClient = new FlowSseClient(this.httpClient);
}
/**
* Creates a new builder for {@link HttpClientSseClientTransport}.
* @param baseUri the base URI of the MCP server
* @return a new builder instance
*/
public static Builder builder(String baseUri) {
return new Builder(baseUri);
}
/**
* Builder for {@link HttpClientSseClientTransport}.
*/
public static class Builder {
private final String baseUri;
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
private HttpClient.Builder clientBuilder = HttpClient.newBuilder();
private ObjectMapper objectMapper = new ObjectMapper();
/**
* Creates a new builder with the specified base URI.
* @param baseUri the base URI of the MCP server
*/
public Builder(String baseUri) {
Assert.hasText(baseUri, "baseUri must not be empty");
this.baseUri = baseUri;
}
/**
* Sets the SSE endpoint path.
* @param sseEndpoint the SSE endpoint path
* @return this builder
*/
public Builder sseEndpoint(String sseEndpoint) {
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
this.sseEndpoint = sseEndpoint;
return this;
}
/**
* Sets the HTTP client builder.
* @param clientBuilder the HTTP client builder
* @return this builder
*/
public Builder clientBuilder(HttpClient.Builder clientBuilder) {
Assert.notNull(clientBuilder, "clientBuilder must not be null");
this.clientBuilder = clientBuilder;
return this;
}
/**
* Sets the object mapper for JSON serialization/deserialization.
* @param objectMapper the object mapper
* @return this builder
*/
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "objectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
/**
* Builds a new {@link HttpClientSseClientTransport} instance.
* @return a new transport instance
*/
public HttpClientSseClientTransport build() {
return new HttpClientSseClientTransport(clientBuilder, baseUri, sseEndpoint, objectMapper);
}
}
/**
* Establishes the SSE connection with the server and sets up message handling.
*
@ -137,7 +229,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
CompletableFuture<Void> future = new CompletableFuture<>();
connectionFuture.set(future);
sseClient.subscribe(this.baseUri + SSE_ENDPOINT, new FlowSseClient.SseEventHandler() {
sseClient.subscribe(this.baseUri + this.sseEndpoint, new FlowSseClient.SseEventHandler() {
@Override
public void onEvent(SseEvent event) {
if (isClosing) {

View file

@ -18,6 +18,7 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransport;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.util.Assert;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
@ -170,8 +171,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String pathInfo = request.getPathInfo();
if (!sseEndpoint.equals(pathInfo)) {
String requestURI = request.getRequestURI();
if (!requestURI.endsWith(sseEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
@ -225,8 +226,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
return;
}
String pathInfo = request.getPathInfo();
if (!messageEndpoint.equals(pathInfo)) {
String requestURI = request.getRequestURI();
if (!requestURI.endsWith(messageEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
@ -429,4 +430,81 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
/**
* Creates a new Builder instance for configuring and creating instances of
* HttpServletSseServerTransportProvider.
* @return A new Builder instance
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of HttpServletSseServerTransportProvider.
* <p>
* This builder provides a fluent API for configuring and creating instances of
* HttpServletSseServerTransportProvider with custom settings.
*/
public static class Builder {
private ObjectMapper objectMapper = new ObjectMapper();
private String messageEndpoint;
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
/**
* Sets the JSON object mapper to use for message serialization/deserialization.
* @param objectMapper The object mapper to use
* @return This builder instance for method chaining
*/
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
/**
* Sets the endpoint path where clients will send their messages.
* @param messageEndpoint The message endpoint path
* @return This builder instance for method chaining
*/
public Builder messageEndpoint(String messageEndpoint) {
Assert.hasText(messageEndpoint, "Message endpoint must not be empty");
this.messageEndpoint = messageEndpoint;
return this;
}
/**
* Sets the endpoint path where clients will establish SSE connections.
* <p>
* If not specified, the default value of {@link #DEFAULT_SSE_ENDPOINT} will be
* used.
* @param sseEndpoint The SSE endpoint path
* @return This builder instance for method chaining
*/
public Builder sseEndpoint(String sseEndpoint) {
Assert.hasText(sseEndpoint, "SSE endpoint must not be empty");
this.sseEndpoint = sseEndpoint;
return this;
}
/**
* Builds a new instance of HttpServletSseServerTransportProvider with the
* configured settings.
* @return A new HttpServletSseServerTransportProvider instance
* @throws IllegalStateException if objectMapper or messageEndpoint is not set
*/
public HttpServletSseServerTransportProvider build() {
if (objectMapper == null) {
throw new IllegalStateException("ObjectMapper must be set");
}
if (messageEndpoint == null) {
throw new IllegalStateException("MessageEndpoint must be set");
}
return new HttpServletSseServerTransportProvider(objectMapper, messageEndpoint, sseEndpoint);
}
}
}

View file

@ -4,7 +4,6 @@
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import org.junit.jupiter.api.Timeout;
@ -19,7 +18,7 @@ class ServletSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
@Override
protected McpServerTransportProvider createMcpTransportProvider() {
return new HttpServletSseServerTransportProvider(new ObjectMapper(), "/mcp/message");
return HttpServletSseServerTransportProvider.builder().messageEndpoint("/mcp/message").build();
}
}

View file

@ -4,7 +4,6 @@
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import org.junit.jupiter.api.Timeout;
@ -19,7 +18,7 @@ class ServletSseMcpSyncServerTests extends AbstractMcpSyncServerTests {
@Override
protected McpServerTransportProvider createMcpTransportProvider() {
return new HttpServletSseServerTransportProvider(new ObjectMapper(), "/mcp/message");
return HttpServletSseServerTransportProvider.builder().messageEndpoint("/mcp/message").build();
}
}

View file

@ -47,7 +47,9 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
private static final int PORT = 8185;
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
private HttpServletSseServerTransportProvider mcpServerTransportProvider;
@ -66,7 +68,11 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
Context context = tomcat.addContext("", baseDir);
// Create and configure the transport provider
mcpServerTransportProvider = new HttpServletSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder()
.objectMapper(new ObjectMapper())
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
.build();
// Add transport servlet to Tomcat
org.apache.catalina.Wrapper wrapper = context.createWrapper();
@ -87,7 +93,9 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
throw new RuntimeException("Failed to start Tomcat", e);
}
this.clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT));
this.clientBuilder = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
.build());
}
@AfterEach