feat: Add Spring WebMVC streamable server transport provider (#425)

- Add WebMvcStreamableServerTransportProvider with SSE support for streamable sessions
- Support GET, POST, DELETE endpoints for MCP protocol operations
- Implement thread-safe SSE operations using ReentrantLock in WebMvcSseServerTransportProvider
- Add test infrastructure with AbstractMcpClientServerIntegrationTests
  - Refactor WebMvcStreamableIntegrationTests to use parameterized tests
  - Support testing with both HttpClient and WebFlux transports
- Add streamable transport tests for both async and sync server modes
- Refactor existing WebMVC SSE integration tests to use shared test base
- Add error handling improvements in McpStreamableServerSession
- Update dependencies: add json-unit-assertj for enhanced JSON testing
- Reorganize POM dependencies and add mcp-spring-webflux test dependency
- Wrap handler invocations with Mono.defer for lazy evaluation
   - Wrap consumer, tool, resource, prompt, and completion handler calls with Mono.defer()
   - Ensures proper lazy evaluation and error handling in reactive streams

Related to #72

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
Co-authored-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-07-30 14:38:16 +02:00 • committed by GitHub
commit 73515346ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2410 additions and 1159 deletions

View file

@ -0,0 +1,121 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.Timeout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
import reactor.netty.DisposableServer;
/**
* Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransportProvider}.
*
* @author Christian Tzolov
*/
@Timeout(15) // Giving extra time beyond the client timeout
class WebMcpStreamableAsyncServerTransportTests extends AbstractMcpAsyncServerTests {
private static final int PORT = TestUtil.findAvailablePort();
private static final String MCP_ENDPOINT = "/mcp";
private DisposableServer httpServer;
private AnnotationConfigWebApplicationContext appContext;
private Tomcat tomcat;
private McpStreamableServerTransportProvider transportProvider;
@Configuration
@EnableWebMvc
static class TestConfig {
@Bean
public WebMvcStreamableServerTransportProvider webMvcSseServerTransportProvider() {
return WebMvcStreamableServerTransportProvider.builder()
.objectMapper(new ObjectMapper())
.mcpEndpoint(MCP_ENDPOINT)
.build();
}
@Bean
public RouterFunction<ServerResponse> routerFunction(
WebMvcStreamableServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}
private McpStreamableServerTransportProvider createMcpTransportProvider() {
// 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
transportProvider = appContext.getBean(McpStreamableServerTransportProvider.class);
// 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);
context.addServletMappingDecoded("/*", "dispatcherServlet");
try {
tomcat.start();
tomcat.getConnector(); // Create and start the connector
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
return transportProvider;
}
@Override
protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder() {
return McpServer.async(createMcpTransportProvider());
}
@Override
protected void onStart() {
}
@Override
protected void onClose() {
if (httpServer != null) {
httpServer.disposeNow();
}
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.Timeout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
import reactor.netty.DisposableServer;
/**
* Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransportProvider}.
*
* @author Christian Tzolov
*/
@Timeout(15) // Giving extra time beyond the client timeout
class WebMcpStreamableSyncServerTransportTests extends AbstractMcpSyncServerTests {
private static final int PORT = TestUtil.findAvailablePort();
private static final String MCP_ENDPOINT = "/mcp";
private DisposableServer httpServer;
private AnnotationConfigWebApplicationContext appContext;
private Tomcat tomcat;
private McpStreamableServerTransportProvider transportProvider;
@Configuration
@EnableWebMvc
static class TestConfig {
@Bean
public WebMvcStreamableServerTransportProvider webMvcSseServerTransportProvider() {
return WebMvcStreamableServerTransportProvider.builder()
.objectMapper(new ObjectMapper())
.mcpEndpoint(MCP_ENDPOINT)
.build();
}
@Bean
public RouterFunction<ServerResponse> routerFunction(
WebMvcStreamableServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}
private McpStreamableServerTransportProvider createMcpTransportProvider() {
// 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
transportProvider = appContext.getBean(McpStreamableServerTransportProvider.class);
// 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);
context.addServletMappingDecoded("/*", "dispatcherServlet");
try {
tomcat.start();
tomcat.getConnector(); // Create and start the connector
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
return transportProvider;
}
@Override
protected McpServer.SyncSpecification<?> prepareSyncServerBuilder() {
return McpServer.sync(createMcpTransportProvider());
}
@Override
protected void onStart() {
}
@Override
protected void onClose() {
if (httpServer != null) {
httpServer.disposeNow();
}
}
}

View file

@ -0,0 +1,165 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Duration;
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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import reactor.core.scheduler.Schedulers;
class WebMvcStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private WebMvcStreamableServerTransportProvider mcpServerTransportProvider;
@Configuration
@EnableWebMvc
static class TestConfig {
@Bean
public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransportProvider() {
return WebMvcStreamableServerTransportProvider.builder()
.objectMapper(new ObjectMapper())
.mcpEndpoint(MESSAGE_ENDPOINT)
.build();
}
@Bean
public RouterFunction<ServerResponse> routerFunction(
WebMvcStreamableServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}
private TomcatTestUtil.TomcatServer tomcatServer;
@BeforeEach
public void before() {
tomcatServer = TomcatTestUtil.createTomcatServer("", PORT, TestConfig.class);
try {
tomcatServer.tomcat().start();
assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED);
}
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
clientBuilders
.put("httpclient",
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
.endpoint(MESSAGE_ENDPOINT)
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
clientBuilders.put("webflux",
McpClient.sync(WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
.endpoint(MESSAGE_ENDPOINT)
.build()));
// Get the transport from Spring context
this.mcpServerTransportProvider = tomcatServer.appContext()
.getBean(WebMvcStreamableServerTransportProvider.class);
}
@Override
protected AsyncSpecification<?> prepareAsyncServerBuilder() {
return McpServer.async(this.mcpServerTransportProvider);
}
@Override
protected SyncSpecification<?> prepareSyncServerBuilder() {
return McpServer.sync(this.mcpServerTransportProvider);
}
@AfterEach
public void after() {
reactor.netty.http.HttpResources.disposeLoopsAndConnections();
if (mcpServerTransportProvider != null) {
mcpServerTransportProvider.closeGracefully().block();
}
Schedulers.shutdownNow();
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);
}
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void simple(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var server = McpServer.async(mcpServerTransportProvider)
.serverInfo("test-server", "1.0.0")
.requestTimeout(Duration.ofSeconds(1000))
.build();
try (
// Create client without sampling capabilities
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0"))
.requestTimeout(Duration.ofSeconds(1000))
.build()) {
assertThat(client.initialize()).isNotNull();
}
server.closeGracefully();
}
@Override
protected void prepareClients(int port, String mcpEndpoint) {
clientBuilders.put("httpclient", McpClient
.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build())
.initializationTimeout(Duration.ofHours(10))
.requestTimeout(Duration.ofHours(10)));
clientBuilders.put("webflux",
McpClient.sync(WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl("http://localhost:" + port))
.endpoint(mcpEndpoint)
.build()));
}
}