feat: add WebMVC and HttpServlet stateless server transports

- Add WebMvcStatelessServerTransport for mcp-spring-webmvc module
- Add HttpServletStatelessServerTransport for mcp module
- Implement builder patterns for AsyncToolSpecification and SyncToolSpecification
- Create AbstractStatelessIntegrationTests base class for shared test functionality
- Add integration tests for both transport implementations

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-07-30 23:47:44 +02:00 • committed by Christian Tzolov
commit 8f03a40540
6 changed files with 1822 additions and 0 deletions

View file

@ -0,0 +1,237 @@
package io.modelcontextprotocol.server.transport;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.server.DefaultMcpTransportContext;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
import io.modelcontextprotocol.server.McpTransportContext;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.util.List;
/**
* Implementation of a WebMVC based {@link McpStatelessServerTransport}.
*
* <p>
* This is the non-reactive version of
* {@link io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport}
*
* @author Christian Tzolov
*/
public class WebMvcStatelessServerTransport implements McpStatelessServerTransport {
private static final Logger logger = LoggerFactory.getLogger(WebMvcStatelessServerTransport.class);
private final ObjectMapper objectMapper;
private final String mcpEndpoint;
private final RouterFunction<ServerResponse> routerFunction;
private McpStatelessServerHandler mcpHandler;
private McpTransportContextExtractor<ServerRequest> contextExtractor;
private volatile boolean isClosing = false;
private WebMvcStatelessServerTransport(ObjectMapper objectMapper, String mcpEndpoint,
McpTransportContextExtractor<ServerRequest> contextExtractor) {
Assert.notNull(objectMapper, "objectMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
this.objectMapper = objectMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
this.routerFunction = RouterFunctions.route()
.GET(this.mcpEndpoint, this::handleGet)
.POST(this.mcpEndpoint, this::handlePost)
.build();
}
@Override
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
this.mcpHandler = mcpHandler;
}
@Override
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> this.isClosing = true);
}
/**
* Returns the WebMVC router function that defines the transport's HTTP endpoints.
* This router function should be integrated into the application's web configuration.
*
* <p>
* The router function defines one endpoint handling two HTTP methods:
* <ul>
* <li>GET {messageEndpoint} - Unsupported, returns 405 METHOD NOT ALLOWED</li>
* <li>POST {messageEndpoint} - For handling client requests and notifications</li>
* </ul>
* @return The configured {@link RouterFunction} for handling HTTP requests
*/
public RouterFunction<ServerResponse> getRouterFunction() {
return this.routerFunction;
}
private ServerResponse handleGet(ServerRequest request) {
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
}
private ServerResponse handlePost(ServerRequest request) {
if (isClosing) {
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
}
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
return ServerResponse.badRequest().build();
}
try {
String body = request.body(String.class);
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler
.handleRequest(transportContext, jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(jsonrpcResponse);
}
catch (Exception e) {
logger.error("Failed to handle request: {}", e.getMessage());
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new McpError("Failed to handle request: " + e.getMessage()));
}
}
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
try {
this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
return ServerResponse.accepted().build();
}
catch (Exception e) {
logger.error("Failed to handle notification: {}", e.getMessage());
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new McpError("Failed to handle notification: " + e.getMessage()));
}
}
else {
return ServerResponse.badRequest()
.body(new McpError("The server accepts either requests or notifications"));
}
}
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
return ServerResponse.badRequest().body(new McpError("Invalid message format"));
}
catch (Exception e) {
logger.error("Unexpected error handling message: {}", e.getMessage());
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new McpError("Unexpected error: " + e.getMessage()));
}
}
/**
* Create a builder for the server.
* @return a fresh {@link Builder} instance.
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link WebMvcStatelessServerTransport}.
* <p>
* This builder provides a fluent API for configuring and creating instances of
* WebMvcStatelessServerTransport with custom settings.
*/
public static class Builder {
private ObjectMapper objectMapper;
private String mcpEndpoint = "/mcp";
private McpTransportContextExtractor<ServerRequest> contextExtractor = (serverRequest, context) -> context;
private Builder() {
// used by a static method
}
/**
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
* messages.
* @param objectMapper The ObjectMapper instance. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if objectMapper is null
*/
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
/**
* Sets the endpoint URI where clients should send their JSON-RPC messages.
* @param messageEndpoint The message endpoint URI. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if messageEndpoint is null
*/
public Builder messageEndpoint(String messageEndpoint) {
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
this.mcpEndpoint = messageEndpoint;
return this;
}
/**
* Sets the context extractor that allows providing the MCP feature
* implementations to inspect HTTP transport level metadata that was present at
* HTTP request processing time. This allows to extract custom headers and other
* useful data for use during execution later on in the process.
* @param contextExtractor The contextExtractor to fill in a
* {@link McpTransportContext}.
* @return this builder instance
* @throws IllegalArgumentException if contextExtractor is null
*/
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
/**
* Builds a new instance of {@link WebMvcStatelessServerTransport} with the
* configured settings.
* @return A new WebMvcStatelessServerTransport instance
* @throws IllegalStateException if required parameters are not set
*/
public WebMvcStatelessServerTransport build() {
Assert.notNull(objectMapper, "ObjectMapper must be set");
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new WebMvcStatelessServerTransport(objectMapper, mcpEndpoint, contextExtractor);
}
}
}

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.AbstractStatelessIntegrationTests;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport;
import io.modelcontextprotocol.spec.McpSchema;
import reactor.core.scheduler.Schedulers;
class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();
private static final String MESSAGE_ENDPOINT = "/mcp/message";
private WebMvcStatelessServerTransport mcpServerTransport;
@Configuration
@EnableWebMvc
static class TestConfig {
@Bean
public WebMvcStatelessServerTransport webMvcStatelessServerTransport() {
return WebMvcStatelessServerTransport.builder()
.objectMapper(new ObjectMapper())
.messageEndpoint(MESSAGE_ENDPOINT)
.build();
}
@Bean
public RouterFunction<ServerResponse> routerFunction(WebMvcStatelessServerTransport statelessServerTransport) {
return statelessServerTransport.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.mcpServerTransport = tomcatServer.appContext().getBean(WebMvcStatelessServerTransport.class);
}
@Override
protected StatelessAsyncSpecification prepareAsyncServerBuilder() {
return McpServer.async(this.mcpServerTransport);
}
@Override
protected StatelessSyncSpecification prepareSyncServerBuilder() {
return McpServer.sync(this.mcpServerTransport);
}
@AfterEach
public void after() {
reactor.netty.http.HttpResources.disposeLoopsAndConnections();
if (this.mcpServerTransport != null) {
this.mcpServerTransport.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(this.mcpServerTransport)
.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()));
}
}

View file

@ -0,0 +1,538 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package io.modelcontextprotocol;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.awaitility.Awaitility.await;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.server.McpStatelessSyncServer;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import net.javacrumbs.jsonunit.core.Option;
import reactor.core.publisher.Mono;
public abstract class AbstractStatelessIntegrationTests {
protected ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
abstract protected void prepareClients(int port, String mcpEndpoint);
abstract protected StatelessAsyncSpecification prepareAsyncServerBuilder();
abstract protected StatelessSyncSpecification prepareSyncServerBuilder();
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void simple(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var server = prepareAsyncServerBuilder().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();
}
// ---------------------------------------
// Tools Tests
// ---------------------------------------
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {}
}
""";
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testToolCallSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((ctx, request) -> {
try {
HttpResponse<String> response = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder()
.uri(URI.create(
"https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md"))
.GET()
.build(), HttpResponse.BodyHandlers.ofString());
String responseBody = response.body();
assertThat(responseBody).isNotBlank();
}
catch (Exception e) {
e.printStackTrace();
}
return callResponse;
})
.build();
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
assertThat(response).isNotNull().isEqualTo(callResponse);
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
McpStatelessSyncServer mcpServer = prepareSyncServerBuilder()
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(McpStatelessServerFeatures.SyncToolSpecification.builder()
.tool(Tool.builder()
.name("tool1")
.description("tool1 description")
.inputSchema(emptyJsonSchema)
.build())
.callHandler((context, request) -> {
// We trigger a timeout on blocking read, raising an exception
Mono.never().block(Duration.ofSeconds(1));
return null;
})
.build())
.build();
try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// We expect the tool call to fail immediately with the exception raised by
// the offending tool
// instead of getting back a timeout.
assertThatExceptionOfType(McpError.class)
.isThrownBy(() -> mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())))
.withMessageContaining("Timeout on blocking read");
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testToolListChangeHandlingSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((ctx, request) -> {
// perform a blocking call to a remote service
try {
HttpResponse<String> response = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder()
.uri(URI.create(
"https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md"))
.GET()
.build(), HttpResponse.BodyHandlers.ofString());
String responseBody = response.body();
assertThat(responseBody).isNotBlank();
}
catch (Exception e) {
e.printStackTrace();
}
return callResponse;
})
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
// perform a blocking call to a remote service
try {
HttpResponse<String> response = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder()
.uri(URI.create(
"https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md"))
.GET()
.build(), HttpResponse.BodyHandlers.ofString());
String responseBody = response.body();
assertThat(responseBody).isNotBlank();
}
catch (Exception e) {
e.printStackTrace();
}
rootsRef.set(toolsUpdate);
}).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
// Remove a tool
mcpServer.removeTool("tool1");
// Add a new tool
McpStatelessServerFeatures.SyncToolSpecification tool2 = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(Tool.builder()
.name("tool2")
.description("tool2 description")
.inputSchema(emptyJsonSchema)
.build())
.callHandler((exchange, request) -> callResponse)
.build();
mcpServer.addTool(tool2);
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testInitialize(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var mcpServer = prepareSyncServerBuilder().build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
}
mcpServer.close();
}
// ---------------------------------------
// Tool Structured Output Schema Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputValidationSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
"required", List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification tool = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> {
String expression = (String) request.arguments().getOrDefault("expression", "2 + 3");
double result = evaluateExpression(expression);
return CallToolResult.builder()
.structuredContent(
Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
.build();
})
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Verify tool is listed with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
// Note: outputSchema might be null in sync server, but validation still works
// Call tool with valid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isFalse();
// In WebMVC, structured content is returned properly
if (response.structuredContent() != null) {
assertThat(response.structuredContent()).containsEntry("result", 5.0)
.containsEntry("operation", "2 + 3")
.containsEntry("timestamp", "2024-01-01T10:00:00Z");
}
else {
// Fallback to checking content if structured content is not available
assertThat(response.content()).isNotEmpty();
}
assertThat(response.structuredContent()).isNotNull();
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(json("""
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputValidationFailure(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification tool = McpStatelessServerFeatures.SyncToolSpecification
.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> {
// Return invalid structured output. Result should be number, missing
// operation
return CallToolResult.builder()
.addTextContent("Invalid calculation")
.structuredContent(Map.of("result", "not-a-number", "extra", "field"))
.build();
})
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Call tool with invalid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
assertThat(errorMessage).contains("Validation failed");
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputMissingStructuredContent(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
var tool = McpStatelessServerFeatures.SyncToolSpecification.builder()
.tool(calculatorTool)
.callHandler((exchange, request) -> {
// Return result without structured content but tool has output schema
return CallToolResult.builder().addTextContent("Calculation completed").build();
})
.build();
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Call tool that should return structured content but doesn't
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
assertThat(errorMessage).isEqualTo(
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testStructuredOutputRuntimeToolAddition(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Start server without tools
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Initially no tools
assertThat(mcpClient.listTools().tools()).isEmpty();
// Add tool with output schema at runtime
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
List.of("message", "count"));
Tool dynamicTool = Tool.builder()
.name("dynamic-tool")
.description("Dynamically added tool")
.outputSchema(outputSchema)
.build();
var toolSpec = McpStatelessServerFeatures.SyncToolSpecification.builder()
.tool(dynamicTool)
.callHandler((exchange, request) -> {
int count = (Integer) request.arguments().getOrDefault("count", 1);
return CallToolResult.builder()
.addTextContent("Dynamic tool executed " + count + " times")
.structuredContent(Map.of("message", "Dynamic execution", "count", count))
.build();
})
.build();
// Add tool to server
mcpServer.addTool(toolSpec);
// Wait for tool list change notification
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(mcpClient.listTools().tools()).hasSize(1);
});
// Verify tool was added with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
// Note: outputSchema might be null in sync server, but validation still works
// Call dynamically added tool
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
assertThat(response).isNotNull();
assertThat(response.isError()).isFalse();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
assertThat(((McpSchema.TextContent) response.content().get(0)).text())
.isEqualTo("Dynamic tool executed 3 times");
assertThat(response.structuredContent()).isNotNull();
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(json("""
{"count":3,"message":"Dynamic execution"}"""));
}
mcpServer.close();
}
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
case "2 + 3" -> 5.0;
case "10 * 2" -> 20.0;
case "7 + 8" -> 15.0;
case "5 + 3" -> 8.0;
default -> 0.0;
};
}
}

View file

@ -22,6 +22,7 @@ import java.util.function.BiFunction;
* support.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
*/
public class McpStatelessServerFeatures {
@ -212,6 +213,59 @@ public class McpStatelessServerFeatures {
return new AsyncToolSpecification(syncToolSpec.tool(), callHandler);
}
/**
* Builder for creating AsyncToolSpecification instances.
*/
public static class Builder {
private McpSchema.Tool tool;
private BiFunction<McpTransportContext, CallToolRequest, Mono<McpSchema.CallToolResult>> callHandler;
/**
* Sets the tool definition.
* @param tool The tool definition including name, description, and parameter
* schema
* @return this builder instance
*/
public Builder tool(McpSchema.Tool tool) {
this.tool = tool;
return this;
}
/**
* Sets the call tool handler function.
* @param callHandler The function that implements the tool's logic
* @return this builder instance
*/
public Builder callHandler(
BiFunction<McpTransportContext, CallToolRequest, Mono<McpSchema.CallToolResult>> callHandler) {
this.callHandler = callHandler;
return this;
}
/**
* Builds the AsyncToolSpecification instance.
* @return a new AsyncToolSpecification instance
* @throws IllegalArgumentException if required fields are not set
*/
public AsyncToolSpecification build() {
Assert.notNull(tool, "Tool must not be null");
Assert.notNull(callHandler, "Call handler function must not be null");
return new AsyncToolSpecification(tool, callHandler);
}
}
/**
* Creates a new builder instance.
* @return a new Builder instance
*/
public static Builder builder() {
return new Builder();
}
}
/**
@ -324,6 +378,55 @@ public class McpStatelessServerFeatures {
*/
public record SyncToolSpecification(McpSchema.Tool tool,
BiFunction<McpTransportContext, CallToolRequest, McpSchema.CallToolResult> callHandler) {
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating SyncToolSpecification instances.
*/
public static class Builder {
private McpSchema.Tool tool;
private BiFunction<McpTransportContext, CallToolRequest, McpSchema.CallToolResult> callHandler;
/**
* Sets the tool definition.
* @param tool The tool definition including name, description, and parameter
* schema
* @return this builder instance
*/
public Builder tool(McpSchema.Tool tool) {
this.tool = tool;
return this;
}
/**
* Sets the call tool handler function.
* @param callHandler The function that implements the tool's logic
* @return this builder instance
*/
public Builder callHandler(
BiFunction<McpTransportContext, CallToolRequest, McpSchema.CallToolResult> callHandler) {
this.callHandler = callHandler;
return this;
}
/**
* Builds the SyncToolSpecification instance.
* @return a new SyncToolSpecification instance
* @throws IllegalArgumentException if required fields are not set
*/
public SyncToolSpecification build() {
Assert.notNull(tool, "Tool must not be null");
Assert.notNull(callHandler, "CallTool function must not be null");
return new SyncToolSpecification(tool, callHandler);
}
}
}
/**

View file

@ -0,0 +1,306 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server.transport;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.DefaultMcpTransportContext;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.server.McpTransportContext;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
import io.modelcontextprotocol.util.Assert;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import reactor.core.publisher.Mono;
/**
* Implementation of an HttpServlet based {@link McpStatelessServerTransport}.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
@WebServlet(asyncSupported = true)
public class HttpServletStatelessServerTransport extends HttpServlet implements McpStatelessServerTransport {
private static final Logger logger = LoggerFactory.getLogger(HttpServletStatelessServerTransport.class);
public static final String UTF_8 = "UTF-8";
public static final String APPLICATION_JSON = "application/json";
public static final String TEXT_EVENT_STREAM = "text/event-stream";
public static final String ACCEPT = "Accept";
public static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}";
private final ObjectMapper objectMapper;
private final String mcpEndpoint;
private McpStatelessServerHandler mcpHandler;
private McpTransportContextExtractor<HttpServletRequest> contextExtractor;
private volatile boolean isClosing = false;
private HttpServletStatelessServerTransport(ObjectMapper objectMapper, String mcpEndpoint,
McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
Assert.notNull(objectMapper, "objectMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
this.objectMapper = objectMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
}
@Override
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
this.mcpHandler = mcpHandler;
}
@Override
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> this.isClosing = true);
}
/**
* Handles GET requests - returns 405 METHOD NOT ALLOWED as stateless transport
* doesn't support GET requests.
* @param request The HTTP servlet request
* @param response The HTTP servlet response
* @throws ServletException If a servlet-specific error occurs
* @throws IOException If an I/O error occurs
*/
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (!requestURI.endsWith(mcpEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}
/**
* Handles POST requests for incoming JSON-RPC messages from clients.
* @param request The HTTP servlet request containing the JSON-RPC message
* @param response The HTTP servlet response
* @throws ServletException If a servlet-specific error occurs
* @throws IOException If an I/O error occurs
*/
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (!requestURI.endsWith(mcpEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (isClosing) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down");
return;
}
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
String accept = request.getHeader(ACCEPT);
if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) {
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
new McpError("Both application/json and text/event-stream required in Accept header"));
return;
}
try {
BufferedReader reader = request.getReader();
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body.toString());
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler
.handleRequest(transportContext, jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
response.setContentType(APPLICATION_JSON);
response.setCharacterEncoding(UTF_8);
response.setStatus(HttpServletResponse.SC_OK);
String jsonResponseText = objectMapper.writeValueAsString(jsonrpcResponse);
PrintWriter writer = response.getWriter();
writer.write(jsonResponseText);
writer.flush();
}
catch (Exception e) {
logger.error("Failed to handle request: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
new McpError("Failed to handle request: " + e.getMessage()));
}
}
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
try {
this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
response.setStatus(HttpServletResponse.SC_ACCEPTED);
}
catch (Exception e) {
logger.error("Failed to handle notification: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
new McpError("Failed to handle notification: " + e.getMessage()));
}
}
else {
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST,
new McpError("The server accepts either requests or notifications"));
}
}
catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, new McpError("Invalid message format"));
}
catch (Exception e) {
logger.error("Unexpected error handling message: {}", e.getMessage());
this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
new McpError("Unexpected error: " + e.getMessage()));
}
}
/**
* Sends an error response to the client.
* @param response The HTTP servlet response
* @param httpCode The HTTP status code
* @param mcpError The MCP error to send
* @throws IOException If an I/O error occurs
*/
private void responseError(HttpServletResponse response, int httpCode, McpError mcpError) throws IOException {
response.setContentType(APPLICATION_JSON);
response.setCharacterEncoding(UTF_8);
response.setStatus(httpCode);
String jsonError = objectMapper.writeValueAsString(mcpError);
PrintWriter writer = response.getWriter();
writer.write(jsonError);
writer.flush();
}
/**
* Cleans up resources when the servlet is being destroyed.
* <p>
* This method ensures a graceful shutdown before calling the parent's destroy method.
*/
@Override
public void destroy() {
closeGracefully().block();
super.destroy();
}
/**
* Create a builder for the server.
* @return a fresh {@link Builder} instance.
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link HttpServletStatelessServerTransport}.
* <p>
* This builder provides a fluent API for configuring and creating instances of
* HttpServletStatelessServerTransport with custom settings.
*/
public static class Builder {
private ObjectMapper objectMapper;
private String mcpEndpoint = "/mcp";
private McpTransportContextExtractor<HttpServletRequest> contextExtractor = (serverRequest, context) -> context;
private Builder() {
// used by a static method
}
/**
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
* messages.
* @param objectMapper The ObjectMapper instance. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if objectMapper is null
*/
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
/**
* Sets the endpoint URI where clients should send their JSON-RPC messages.
* @param messageEndpoint The message endpoint URI. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if messageEndpoint is null
*/
public Builder messageEndpoint(String messageEndpoint) {
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
this.mcpEndpoint = messageEndpoint;
return this;
}
/**
* Sets the context extractor that allows providing the MCP feature
* implementations to inspect HTTP transport level metadata that was present at
* HTTP request processing time. This allows to extract custom headers and other
* useful data for use during execution later on in the process.
* @param contextExtractor The contextExtractor to fill in a
* {@link McpTransportContext}.
* @return this builder instance
* @throws IllegalArgumentException if contextExtractor is null
*/
public Builder contextExtractor(McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
/**
* Builds a new instance of {@link HttpServletStatelessServerTransport} with the
* configured settings.
* @return A new HttpServletStatelessServerTransport instance
* @throws IllegalStateException if required parameters are not set
*/
public HttpServletStatelessServerTransport build() {
Assert.notNull(objectMapper, "ObjectMapper must be set");
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(objectMapper, mcpEndpoint, contextExtractor);
}
}
}

View file

@ -0,0 +1,473 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.web.client.RestClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import net.javacrumbs.jsonunit.core.Option;
class HttpServletStatelessIntegrationTests {
private static final int PORT = TomcatTestUtil.findAvailablePort();
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
private HttpServletStatelessServerTransport mcpStatelessServerTransport;
ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
private Tomcat tomcat;
@BeforeEach
public void before() {
this.mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder()
.objectMapper(new ObjectMapper())
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
.build();
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpStatelessServerTransport);
try {
tomcat.start();
assertThat(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(CUSTOM_MESSAGE_ENDPOINT)
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
}
@AfterEach
public void after() {
if (mcpStatelessServerTransport != null) {
mcpStatelessServerTransport.closeGracefully().block();
}
if (tomcat != null) {
try {
tomcat.stop();
tomcat.destroy();
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to stop Tomcat", e);
}
}
}
// ---------------------------------------
// Tools Tests
// ---------------------------------------
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {}
}
""";
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testToolCallSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpStatelessServerFeatures.SyncToolSpecification tool1 = new McpStatelessServerFeatures.SyncToolSpecification(
new Tool("tool1", "tool1 description", emptyJsonSchema), (transportContext, request) -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
.uri("https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
return callResponse;
});
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testInitialize(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var mcpServer = McpServer.sync(mcpStatelessServerTransport).build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
}
mcpServer.close();
}
// ---------------------------------------
// Completion Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : Completion call")
@ValueSource(strings = { "httpclient" })
void testCompletionShouldReturnExpectedSuggestions(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
var expectedValues = List.of("python", "pytorch", "pyside");
var completionResponse = new CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
true // hasMore
));
AtomicReference<CompleteRequest> samplingRequest = new AtomicReference<>();
BiFunction<McpTransportContext, CompleteRequest, CompleteResult> completionHandler = (transportContext,
request) -> {
samplingRequest.set(request);
return completionResponse;
};
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.capabilities(ServerCapabilities.builder().completions().build())
.prompts(new McpStatelessServerFeatures.SyncPromptSpecification(
new Prompt("code_review", "Code review", "this is code review prompt",
List.of(new PromptArgument("language", "Language", "string", false))),
(transportContext, getPromptRequest) -> null))
.completions(new McpStatelessServerFeatures.SyncCompletionSpecification(
new PromptReference("ref/prompt", "code_review", "Code review"), completionHandler))
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
CompleteRequest request = new CompleteRequest(
new PromptReference("ref/prompt", "code_review", "Code review"),
new CompleteRequest.CompleteArgument("language", "py"));
CompleteResult result = mcpClient.completeCompletion(request);
assertThat(result).isNotNull();
assertThat(samplingRequest.get().argument().name()).isEqualTo("language");
assertThat(samplingRequest.get().argument().value()).isEqualTo("py");
assertThat(samplingRequest.get().ref().type()).isEqualTo("ref/prompt");
}
mcpServer.close();
}
// ---------------------------------------
// Tool Structured Output Schema Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputValidationSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of(
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
"required", List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
calculatorTool, (transportContext, request) -> {
String expression = (String) request.arguments().getOrDefault("expression", "2 + 3");
double result = evaluateExpression(expression);
return CallToolResult.builder()
.structuredContent(
Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
.build();
});
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Verify tool is listed with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
// Note: outputSchema might be null in sync server, but validation still works
// Call tool with valid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isFalse();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(json("""
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
assertThat(response.structuredContent()).isNotNull();
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(json("""
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputValidationFailure(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
List.of("result", "operation"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
calculatorTool, (transportContext, request) -> {
// Return invalid structured output. Result should be number, missing
// operation
return CallToolResult.builder()
.addTextContent("Invalid calculation")
.structuredContent(Map.of("result", "not-a-number", "extra", "field"))
.build();
});
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Call tool with invalid structured output
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
assertThat(errorMessage).contains("Validation failed");
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputMissingStructuredContent(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Create a tool with output schema
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
Tool calculatorTool = Tool.builder()
.name("calculator")
.description("Performs mathematical calculations")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
calculatorTool, (transportContext, request) -> {
// Return result without structured content but tool has output schema
return CallToolResult.builder().addTextContent("Calculation completed").build();
});
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.instructions("bla")
.tools(tool)
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Call tool that should return structured content but doesn't
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
assertThat(response).isNotNull();
assertThat(response.isError()).isTrue();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
assertThat(errorMessage).isEqualTo(
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
}
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputRuntimeToolAddition(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
// Start server without tools
var mcpServer = McpServer.sync(mcpStatelessServerTransport)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Initially no tools
assertThat(mcpClient.listTools().tools()).isEmpty();
// Add tool with output schema at runtime
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
List.of("message", "count"));
Tool dynamicTool = Tool.builder()
.name("dynamic-tool")
.description("Dynamically added tool")
.outputSchema(outputSchema)
.build();
McpStatelessServerFeatures.SyncToolSpecification toolSpec = new McpStatelessServerFeatures.SyncToolSpecification(
dynamicTool, (transportContext, request) -> {
int count = (Integer) request.arguments().getOrDefault("count", 1);
return CallToolResult.builder()
.addTextContent("Dynamic tool executed " + count + " times")
.structuredContent(Map.of("message", "Dynamic execution", "count", count))
.build();
});
// Add tool to server
mcpServer.addTool(toolSpec);
// Wait for tool list change notification
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(mcpClient.listTools().tools()).hasSize(1);
});
// Verify tool was added with output schema
var toolsList = mcpClient.listTools();
assertThat(toolsList.tools()).hasSize(1);
assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
// Note: outputSchema might be null in sync server, but validation still works
// Call dynamically added tool
CallToolResult response = mcpClient
.callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
assertThat(response).isNotNull();
assertThat(response.isError()).isFalse();
assertThat(response.content()).hasSize(1);
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
assertThat(((McpSchema.TextContent) response.content().get(0)).text())
.isEqualTo("Dynamic tool executed 3 times");
assertThat(response.structuredContent()).isNotNull();
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(json("""
{"count":3,"message":"Dynamic execution"}"""));
}
mcpServer.close();
}
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
case "2 + 3" -> 5.0;
case "10 * 2" -> 20.0;
case "7 + 8" -> 15.0;
case "5 + 3" -> 8.0;
default -> 0.0;
};
}
}