feat(webmvc): Add support for custom context paths in WebMvcSseServerTransportProvider

Adds the ability to specify a base URL for message endpoints in WebMvcSseServerTransportProvider,
enabling proper handling of custom servlet context paths in Spring WebMVC applications.
This ensures that clients receive the correct full endpoint URL when connecting through SSE.

- Add messageBaseUrl field to WebMvcSseServerTransportProvider
- Create new constructor that accepts messageBaseUrl parameter
- Update endpoint event to include base URL in the message endpoint
- Add TomcatTestUtil class to simplify test server creation
- Add WebMvcSseCustomContextPathTests to verify custom context path functionality
- Refactor WebMvcSseIntegrationTests to use the new TomcatTestUtil

Co-authored-by: Christian Tzolov <christian.tzolov@broadcom.com>
Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
minguncle 2025-03-27 15:53:30 +08:00 • committed by Christian Tzolov
commit 0db4c0f70d
5 changed files with 216 additions and 63 deletions

View file

@ -91,6 +91,8 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
private final String sseEndpoint;
private final String messageBaseUrl;
private final RouterFunction<ServerResponse> routerFunction;
private McpServerSession.Factory sessionFactory;
@ -105,30 +107,6 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
*/
private volatile boolean isClosing = false;
/**
* Constructs a new WebMvcSseServerTransportProvider instance.
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* of messages.
* @param messageEndpoint The endpoint URI where clients should send their JSON-RPC
* messages via HTTP POST. This endpoint will be communicated to clients through the
* SSE connection's initial endpoint event.
* @param sseEndpoint The endpoint URI where clients establish their SSE connections.
* @throws IllegalArgumentException if any parameter is null
*/
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
this.objectMapper = objectMapper;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.routerFunction = RouterFunctions.route()
.GET(this.sseEndpoint, this::handleSseConnection)
.POST(this.messageEndpoint, this::handleMessage)
.build();
}
/**
* Constructs a new WebMvcSseServerTransportProvider instance with the default SSE
* endpoint.
@ -143,6 +121,49 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
this(objectMapper, messageEndpoint, DEFAULT_SSE_ENDPOINT);
}
/**
* Constructs a new WebMvcSseServerTransportProvider instance.
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* of messages.
* @param messageEndpoint The endpoint URI where clients should send their JSON-RPC
* messages via HTTP POST. This endpoint will be communicated to clients through the
* SSE connection's initial endpoint event.
* @param sseEndpoint The endpoint URI where clients establish their SSE connections.
* @throws IllegalArgumentException if any parameter is null
*/
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) {
this(objectMapper, "", messageEndpoint, sseEndpoint);
}
/**
* Constructs a new WebMvcSseServerTransportProvider instance.
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
* of messages.
* @param messageBaseUrl The base URL for the message endpoint, used to construct the
* full endpoint URL for clients.
* @param messageEndpoint The endpoint URI where clients should send their JSON-RPC
* messages via HTTP POST. This endpoint will be communicated to clients through the
* SSE connection's initial endpoint event.
* @param sseEndpoint The endpoint URI where clients establish their SSE connections.
* @throws IllegalArgumentException if any parameter is null
*/
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String messageBaseUrl, String messageEndpoint,
String sseEndpoint) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
Assert.notNull(messageBaseUrl, "Message base URL must not be null");
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
this.objectMapper = objectMapper;
this.messageBaseUrl = messageBaseUrl;
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.routerFunction = RouterFunctions.route()
.GET(this.sseEndpoint, this::handleSseConnection)
.POST(this.messageEndpoint, this::handleMessage)
.build();
}
@Override
public void setSessionFactory(McpServerSession.Factory sessionFactory) {
this.sessionFactory = sessionFactory;
@ -248,7 +269,7 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
try {
sseBuilder.id(sessionId)
.event(ENDPOINT_EVENT_TYPE)
.data(messageEndpoint + "?sessionId=" + sessionId);
.data(this.messageBaseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
}
catch (Exception e) {
logger.error("Failed to send initial endpoint event: {}", e.getMessage());

View file

@ -0,0 +1,60 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
package io.modelcontextprotocol.server;
import org.apache.catalina.Context;
import org.apache.catalina.startup.Tomcat;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
/**
* @author Christian Tzolov
*/
public class TomcatTestUtil {
public record TomcatServer(Tomcat tomcat, AnnotationConfigWebApplicationContext appContext) {
}
public TomcatServer createTomcatServer(String contextPath, int port, Class<?> componentClass) {
// Set up Tomcat first
var tomcat = new Tomcat();
tomcat.setPort(port);
// Set Tomcat base directory to java.io.tmpdir to avoid permission issues
String baseDir = System.getProperty("java.io.tmpdir");
tomcat.setBaseDir(baseDir);
// Use the same directory for document base
Context context = tomcat.addContext(contextPath, baseDir);
// Create and configure Spring WebMvc context
var appContext = new AnnotationConfigWebApplicationContext();
appContext.register(componentClass);
appContext.setServletContext(context.getServletContext());
appContext.refresh();
// Create DispatcherServlet with our Spring context
DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext);
// Add servlet to Tomcat and get the wrapper
var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet);
wrapper.setLoadOnStartup(1);
wrapper.setAsyncSupported(true);
context.addServletMappingDecoded("/*", "dispatcherServlet");
try {
// Configure and start the connector with async support
var connector = tomcat.getConnector();
connector.setAsyncTimeout(3000); // 3 seconds timeout for async requests
}
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
return new TomcatServer(tomcat, appContext);
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
public class WebMvcSseCustomContextPathTests {
private static final String CUSTOM_CONTEXT_PATH = "/app/1";
private static final int PORT = 8183;
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private WebMvcSseServerTransportProvider mcpServerTransportProvider;
McpClient.SyncSpec clientBuilder;
private TomcatTestUtil.TomcatServer tomcatServer;
@BeforeEach
public void before() {
tomcatServer = new TomcatTestUtil().createTomcatServer(CUSTOM_CONTEXT_PATH, PORT, TestConfig.class);
try {
tomcatServer.tomcat().start();
assertThat(tomcatServer.tomcat().getServer().getState() == LifecycleState.STARTED);
}
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
var clientTransport = HttpClientSseClientTransport.builder("http://localhost:" + PORT)
.sseEndpoint(CUSTOM_CONTEXT_PATH + WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT)
.build();
clientBuilder = McpClient.sync(clientTransport);
mcpServerTransportProvider = tomcatServer.appContext().getBean(WebMvcSseServerTransportProvider.class);
}
@AfterEach
public void after() {
if (mcpServerTransportProvider != null) {
mcpServerTransportProvider.closeGracefully().block();
}
if (tomcatServer.appContext() != null) {
tomcatServer.appContext().close();
}
if (tomcatServer.tomcat() != null) {
try {
tomcatServer.tomcat().stop();
tomcatServer.tomcat().destroy();
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to stop Tomcat", e);
}
}
}
@Test
void testCustomContextPath() {
McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").build();
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
assertThat(client.initialize()).isNotNull();
}
@Configuration
@EnableWebMvc
static class TestConfig {
@Bean
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
return new WebMvcSseServerTransportProvider(new ObjectMapper(), CUSTOM_CONTEXT_PATH, MESSAGE_ENDPOINT,
WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT);
}
@Bean
public RouterFunction<ServerResponse> routerFunction(WebMvcSseServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}
}

View file

@ -25,10 +25,8 @@ import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -38,15 +36,12 @@ import reactor.test.StepVerifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.mock;
public class WebMvcSseIntegrationTests {
@ -75,55 +70,26 @@ public class WebMvcSseIntegrationTests {
}
private Tomcat tomcat;
private AnnotationConfigWebApplicationContext appContext;
private TomcatTestUtil.TomcatServer tomcatServer;
@BeforeEach
public void before() {
// Set up Tomcat first
tomcat = new Tomcat();
tomcat.setPort(PORT);
// Set Tomcat base directory to java.io.tmpdir to avoid permission issues
String baseDir = System.getProperty("java.io.tmpdir");
tomcat.setBaseDir(baseDir);
// Use the same directory for document base
Context context = tomcat.addContext("", baseDir);
// Create and configure Spring WebMvc context
appContext = new AnnotationConfigWebApplicationContext();
appContext.register(TestConfig.class);
appContext.setServletContext(context.getServletContext());
appContext.refresh();
// Get the transport from Spring context
mcpServerTransportProvider = appContext.getBean(WebMvcSseServerTransportProvider.class);
// Create DispatcherServlet with our Spring context
DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext);
// dispatcherServlet.setThrowExceptionIfNoHandlerFound(true);
// Add servlet to Tomcat and get the wrapper
var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet);
wrapper.setLoadOnStartup(1);
wrapper.setAsyncSupported(true);
context.addServletMappingDecoded("/*", "dispatcherServlet");
tomcatServer = new TomcatTestUtil().createTomcatServer("", PORT, TestConfig.class);
try {
// Configure and start the connector with async support
var connector = tomcat.getConnector();
connector.setAsyncTimeout(3000); // 3 seconds timeout for async requests
tomcat.start();
assertThat(tomcat.getServer().getState() == LifecycleState.STARTED);
tomcatServer.tomcat().start();
assertThat(tomcatServer.tomcat().getServer().getState() == LifecycleState.STARTED);
}
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
this.clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT));
clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT));
// Get the transport from Spring context
mcpServerTransportProvider = tomcatServer.appContext().getBean(WebMvcSseServerTransportProvider.class);
}
@AfterEach
@ -131,13 +97,13 @@ public class WebMvcSseIntegrationTests {
if (mcpServerTransportProvider != null) {
mcpServerTransportProvider.closeGracefully().block();
}
if (appContext != null) {
appContext.close();
if (tomcatServer.appContext() != null) {
tomcatServer.appContext().close();
}
if (tomcat != null) {
if (tomcatServer.tomcat() != null) {
try {
tomcat.stop();
tomcat.destroy();
tomcatServer.tomcat().stop();
tomcatServer.tomcat().destroy();
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to stop Tomcat", e);

View file

@ -80,6 +80,7 @@
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>
</dependencies>