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
2 changed files with 2410 additions and 1159 deletions

View file

@ -296,7 +296,7 @@ public class McpAsyncServer {
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
return (exchange, params) -> exchange.listRoots()
.flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
.flatMap(consumer -> consumer.apply(exchange, listRootsResult.roots()))
.flatMap(consumer -> Mono.defer(() -> consumer.apply(exchange, listRootsResult.roots())))
.onErrorResume(error -> {
logger.error("Error handling roots list change notification", error);
return Mono.empty();
@ -506,7 +506,7 @@ public class McpAsyncServer {
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
}
return toolSpecification.map(tool -> tool.callHandler().apply(exchange, callToolRequest))
return toolSpecification.map(tool -> Mono.defer(() -> tool.callHandler().apply(exchange, callToolRequest)))
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
};
}
@ -634,7 +634,7 @@ public class McpAsyncServer {
.findFirst()
.orElseThrow(() -> new McpError("Resource not found: " + resourceUri));
return specification.readHandler().apply(exchange, resourceRequest);
return Mono.defer(() -> specification.readHandler().apply(exchange, resourceRequest));
};
}
@ -740,7 +740,7 @@ public class McpAsyncServer {
return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
}
return specification.promptHandler().apply(exchange, promptRequest);
return Mono.defer(() -> specification.promptHandler().apply(exchange, promptRequest));
};
}
@ -845,7 +845,7 @@ public class McpAsyncServer {
return Mono.error(new McpError("AsyncCompletionSpecification not found: " + request.ref()));
}
return specification.completionHandler().apply(exchange, request);
return Mono.defer(() -> specification.completionHandler().apply(exchange, request));
};
}

View file

@ -4,9 +4,17 @@
package io.modelcontextprotocol.server;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
@ -18,18 +26,9 @@ import io.modelcontextprotocol.spec.McpSchema.Resource;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Test suite for the {@link McpAsyncServer} that can be used with different
* {@link io.modelcontextprotocol.spec.McpServerTransportProvider} implementations.
@ -65,10 +64,7 @@ public abstract class AbstractMcpAsyncServerTests {
// ---------------------------------------
// Server Lifecycle Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "sse", "streamable" })
void testConstructorWithInvalidArguments(String serverType) {
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> McpServer.async((McpServerTransportProvider) null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport provider must not be null");