refactor: remove deprecated 0.7.0 code
These changes are part of the planned deprecation cycle announced in 0.8.0, with the deprecated classes scheduled for removal in 0.9.0 - Delete WebFluxSseServerTransport, WebMvcSseServerTransport, StdioServerTransport, and HttpServletSseServerTransport - Remove deprecated interfaces: ServerMcpTransport, ClientMcpTransport - Delete DefaultMcpSession implementation - Remove all deprecated test classes for the removed implementations - Update references to use McpServerTransport and McpClientTransport interfaces - Split MockMcpTransport into client and server implementations * Rename MockMcpTransport to MockMcpClientTransport in mcp/src/test * Create new MockMcpServerTransport implementation * Add MockMcpServerTransportProvider for server tests * Mark MockMcpTransport in mcp-test module as deprecated * Update all test classes to use the new implementations Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
7827cdc113
commit
25f3bad68d
9 changed files with 203 additions and 7660 deletions
|
|
@ -1,413 +0,0 @@
|
|||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
||||
/**
|
||||
* Server-side implementation of the MCP (Model Context Protocol) HTTP transport using
|
||||
* Server-Sent Events (SSE). This implementation provides a bidirectional communication
|
||||
* channel between MCP clients and servers using HTTP POST for client-to-server messages
|
||||
* and SSE for server-to-client messages.
|
||||
*
|
||||
* <p>
|
||||
* Key features:
|
||||
* <ul>
|
||||
* <li>Implements the {@link ServerMcpTransport} interface for MCP server transport
|
||||
* functionality</li>
|
||||
* <li>Uses WebFlux for non-blocking request handling and SSE support</li>
|
||||
* <li>Maintains client sessions for reliable message delivery</li>
|
||||
* <li>Supports graceful shutdown with session cleanup</li>
|
||||
* <li>Thread-safe message broadcasting to multiple clients</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The transport sets up two main endpoints:
|
||||
* <ul>
|
||||
* <li>SSE endpoint (/sse) - For establishing SSE connections with clients</li>
|
||||
* <li>Message endpoint (configurable) - For receiving JSON-RPC messages from clients</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This implementation is thread-safe and can handle multiple concurrent client
|
||||
* connections. It uses {@link ConcurrentHashMap} for session management and Reactor's
|
||||
* {@link Sinks} for thread-safe message broadcasting.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Alexandros Pappas
|
||||
* @see ServerMcpTransport
|
||||
* @see ServerSentEvent
|
||||
* @deprecated This class will be removed in 0.9.0. Use
|
||||
* {@link WebFluxSseServerTransportProvider}.
|
||||
*/
|
||||
@Deprecated
|
||||
public class WebFluxSseServerTransport implements ServerMcpTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxSseServerTransport.class);
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages sent through the SSE connection.
|
||||
*/
|
||||
public static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/**
|
||||
* Event type for sending the message endpoint URI to clients.
|
||||
*/
|
||||
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path as specified by the MCP transport specification.
|
||||
*/
|
||||
public static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final String messageEndpoint;
|
||||
|
||||
private final String sseEndpoint;
|
||||
|
||||
private final RouterFunction<?> routerFunction;
|
||||
|
||||
/**
|
||||
* Map of active client sessions, keyed by session ID.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, ClientSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Flag indicating if the transport is shutting down.
|
||||
*/
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> connectHandler;
|
||||
|
||||
/**
|
||||
* Constructs a new WebFlux SSE server transport instance.
|
||||
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
|
||||
* of MCP messages. Must not be null.
|
||||
* @param messageEndpoint The endpoint URI where clients should send their JSON-RPC
|
||||
* messages. This endpoint will be communicated to clients during SSE connection
|
||||
* setup. Must not be null.
|
||||
* @throws IllegalArgumentException if either parameter is null
|
||||
*/
|
||||
public WebFluxSseServerTransport(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 WebFlux SSE server transport instance with the default SSE
|
||||
* endpoint.
|
||||
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
|
||||
* of MCP messages. Must not be null.
|
||||
* @param messageEndpoint The endpoint URI where clients should send their JSON-RPC
|
||||
* messages. This endpoint will be communicated to clients during SSE connection
|
||||
* setup. Must not be null.
|
||||
* @throws IllegalArgumentException if either parameter is null
|
||||
*/
|
||||
public WebFluxSseServerTransport(ObjectMapper objectMapper, String messageEndpoint) {
|
||||
this(objectMapper, messageEndpoint, DEFAULT_SSE_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the message handler for this transport. In the WebFlux SSE
|
||||
* implementation, this method stores the handler for processing incoming messages but
|
||||
* doesn't establish any connections since the server accepts connections rather than
|
||||
* initiating them.
|
||||
* @param handler A function that processes incoming JSON-RPC messages and returns
|
||||
* responses. This handler will be called for each message received through the
|
||||
* message endpoint.
|
||||
* @return An empty Mono since the server doesn't initiate connections
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
|
||||
this.connectHandler = handler;
|
||||
// Server-side transport doesn't initiate connections
|
||||
return Mono.empty().then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a JSON-RPC message to all connected clients through their SSE
|
||||
* connections. The message is serialized to JSON and sent as a server-sent event to
|
||||
* each active session.
|
||||
*
|
||||
* <p>
|
||||
* The method:
|
||||
* <ul>
|
||||
* <li>Serializes the message to JSON</li>
|
||||
* <li>Creates a server-sent event with the message data</li>
|
||||
* <li>Attempts to send the event to all active sessions</li>
|
||||
* <li>Tracks and reports any delivery failures</li>
|
||||
* </ul>
|
||||
* @param message The JSON-RPC message to broadcast
|
||||
* @return A Mono that completes when the message has been sent to all sessions, or
|
||||
* errors if any session fails to receive the message
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
if (sessions.isEmpty()) {
|
||||
logger.debug("No active sessions to broadcast message to");
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return Mono.<Void>create(sink -> {
|
||||
try {// @formatter:off
|
||||
String jsonText = objectMapper.writeValueAsString(message);
|
||||
ServerSentEvent<Object> event = ServerSentEvent.builder()
|
||||
.event(MESSAGE_EVENT_TYPE)
|
||||
.data(jsonText)
|
||||
.build();
|
||||
|
||||
logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
|
||||
|
||||
List<String> failedSessions = sessions.values().stream()
|
||||
.filter(session -> session.messageSink.tryEmitNext(event).isFailure())
|
||||
.map(session -> session.id)
|
||||
.toList();
|
||||
|
||||
if (failedSessions.isEmpty()) {
|
||||
logger.debug("Successfully broadcast message to all sessions");
|
||||
sink.success();
|
||||
}
|
||||
else {
|
||||
String error = "Failed to broadcast message to sessions: " + String.join(", ", failedSessions);
|
||||
logger.error(error);
|
||||
sink.error(new RuntimeException(error));
|
||||
} // @formatter:on
|
||||
}
|
||||
catch (IOException e) {
|
||||
logger.error("Failed to serialize message: {}", e.getMessage());
|
||||
sink.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts data from one type to another using the configured ObjectMapper. This
|
||||
* method is primarily used for converting between different representations of
|
||||
* JSON-RPC message data.
|
||||
* @param <T> The target type to convert to
|
||||
* @param data The source data to convert
|
||||
* @param typeRef Type reference describing the target type
|
||||
* @return The converted data
|
||||
* @throws IllegalArgumentException if the conversion fails
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return this.objectMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a graceful shutdown of the transport. This method ensures all active
|
||||
* sessions are properly closed and cleaned up.
|
||||
*
|
||||
* <p>
|
||||
* The shutdown process:
|
||||
* <ul>
|
||||
* <li>Marks the transport as closing to prevent new connections</li>
|
||||
* <li>Closes each active session</li>
|
||||
* <li>Removes closed sessions from the sessions map</li>
|
||||
* <li>Times out after 5 seconds if shutdown takes too long</li>
|
||||
* </ul>
|
||||
* @return A Mono that completes when all sessions have been closed
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> {
|
||||
isClosing = true;
|
||||
logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size());
|
||||
}).then(Mono.when(sessions.values().stream().map(session -> {
|
||||
String sessionId = session.id;
|
||||
return Mono.fromRunnable(() -> session.close())
|
||||
.then(Mono.delay(Duration.ofMillis(100)))
|
||||
.then(Mono.fromRunnable(() -> sessions.remove(sessionId)));
|
||||
}).toList()))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.doOnSuccess(v -> logger.debug("Graceful shutdown completed"))
|
||||
.doOnError(e -> logger.error("Error during graceful shutdown: {}", e.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFlux 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 two endpoints:
|
||||
* <ul>
|
||||
* <li>GET {sseEndpoint} - For establishing SSE connections</li>
|
||||
* <li>POST {messageEndpoint} - For receiving client messages</li>
|
||||
* </ul>
|
||||
* @return The configured {@link RouterFunction} for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<?> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles new SSE connection requests from clients. Creates a new session for each
|
||||
* connection and sets up the SSE event stream.
|
||||
*
|
||||
* <p>
|
||||
* The handler performs the following steps:
|
||||
* <ul>
|
||||
* <li>Generates a unique session ID</li>
|
||||
* <li>Creates a new ClientSession instance</li>
|
||||
* <li>Sends the message endpoint URI as an initial event</li>
|
||||
* <li>Sets up message forwarding for the session</li>
|
||||
* <li>Handles connection cleanup on completion or errors</li>
|
||||
* </ul>
|
||||
* @param request The incoming server request
|
||||
* @return A response with the SSE event stream
|
||||
*/
|
||||
private Mono<ServerResponse> handleSseConnection(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
logger.debug("Creating new SSE connection for session: {}", sessionId);
|
||||
ClientSession session = new ClientSession(sessionId);
|
||||
this.sessions.put(sessionId, session);
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(Flux.<ServerSentEvent<?>>create(sink -> {
|
||||
// Send initial endpoint event
|
||||
logger.debug("Sending initial endpoint event to session: {}", sessionId);
|
||||
sink.next(ServerSentEvent.builder().event(ENDPOINT_EVENT_TYPE).data(messageEndpoint).build());
|
||||
|
||||
// Subscribe to session messages
|
||||
session.messageSink.asFlux()
|
||||
.doOnSubscribe(s -> logger.debug("Session {} subscribed to message sink", sessionId))
|
||||
.doOnComplete(() -> {
|
||||
logger.debug("Session {} completed", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
})
|
||||
.doOnError(error -> {
|
||||
logger.error("Error in session {}: {}", sessionId, error.getMessage());
|
||||
sessions.remove(sessionId);
|
||||
})
|
||||
.doOnCancel(() -> {
|
||||
logger.debug("Session {} cancelled", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
})
|
||||
.subscribe(event -> {
|
||||
logger.debug("Forwarding event to session {}: {}", sessionId, event);
|
||||
sink.next(event);
|
||||
}, sink::error, sink::complete);
|
||||
|
||||
sink.onCancel(() -> {
|
||||
logger.debug("Session {} cancelled", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
});
|
||||
}), ServerSentEvent.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients. Deserializes the message and
|
||||
* processes it through the configured message handler.
|
||||
*
|
||||
* <p>
|
||||
* The handler:
|
||||
* <ul>
|
||||
* <li>Deserializes the incoming JSON-RPC message</li>
|
||||
* <li>Passes it through the message handler chain</li>
|
||||
* <li>Returns appropriate HTTP responses based on processing results</li>
|
||||
* <li>Handles various error conditions with appropriate error responses</li>
|
||||
* </ul>
|
||||
* @param request The incoming server request containing the JSON-RPC message
|
||||
* @return A response indicating the message processing result
|
||||
*/
|
||||
private Mono<ServerResponse> handleMessage(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
return request.bodyToMono(String.class).flatMap(body -> {
|
||||
try {
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
|
||||
return Mono.just(message)
|
||||
.transform(this.connectHandler)
|
||||
.flatMap(response -> ServerResponse.ok().build())
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error processing message: {}", error.getMessage());
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.bodyValue(new McpError(error.getMessage()));
|
||||
});
|
||||
}
|
||||
catch (IllegalArgumentException | IOException e) {
|
||||
logger.error("Failed to deserialize message: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an active client SSE connection session. Manages the message sink for
|
||||
* sending events to the client and handles session lifecycle.
|
||||
*
|
||||
* <p>
|
||||
* Each session:
|
||||
* <ul>
|
||||
* <li>Has a unique identifier</li>
|
||||
* <li>Maintains its own message sink for event broadcasting</li>
|
||||
* <li>Supports clean shutdown through the close method</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static class ClientSession {
|
||||
|
||||
private final String id;
|
||||
|
||||
private final Sinks.Many<ServerSentEvent<?>> messageSink;
|
||||
|
||||
ClientSession(String id) {
|
||||
this.id = id;
|
||||
logger.debug("Creating new session: {}", id);
|
||||
this.messageSink = Sinks.many().replay().latest();
|
||||
logger.debug("Session {} initialized with replay sink", id);
|
||||
}
|
||||
|
||||
void close() {
|
||||
logger.debug("Closing session: {}", id);
|
||||
Sinks.EmitResult result = messageSink.tryEmitComplete();
|
||||
if (result.isFailure()) {
|
||||
logger.warn("Failed to complete message sink for session {}: {}", id, result);
|
||||
}
|
||||
else {
|
||||
logger.debug("Successfully completed message sink for session {}", id);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -8,10 +8,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
|
@ -19,7 +18,6 @@ import reactor.core.Exceptions;
|
|||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
|
||||
/**
|
||||
* Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransport}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@Deprecated
|
||||
@Timeout(15) // Giving extra time beyond the client timeout
|
||||
class WebFluxSseMcpAsyncServerDeprecatedTests extends AbstractMcpAsyncServerDeprecatedTests {
|
||||
|
||||
private static final int PORT = 8181;
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
@Override
|
||||
protected ServerMcpTransport createMcpTransport() {
|
||||
var transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
|
||||
return transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClose() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
|
||||
/**
|
||||
* Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransport}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@Deprecated
|
||||
@Timeout(15) // Giving extra time beyond the client timeout
|
||||
class WebFluxSseMcpSyncServerDeprecatecTests extends AbstractMcpSyncServerDeprecatedTests {
|
||||
|
||||
private static final int PORT = 8182;
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private WebFluxSseServerTransport transport;
|
||||
|
||||
@Override
|
||||
protected ServerMcpTransport createMcpTransport() {
|
||||
transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
|
||||
return transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClose() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,459 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
package io.modelcontextprotocol.server.legacy;
|
||||
|
||||
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.Function;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
||||
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.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.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
public class WebFluxSseIntegrationTests {
|
||||
|
||||
private static final int PORT = 8182;
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private WebFluxSseServerTransport mcpServerTransport;
|
||||
|
||||
ConcurrentHashMap<String, McpClient.SyncSpec> clientBulders = new ConcurrentHashMap<>();
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
|
||||
this.mcpServerTransport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
|
||||
clientBulders.put("httpclient", McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT)));
|
||||
clientBulders.put("webflux",
|
||||
McpClient.sync(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT))));
|
||||
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Sampling Tests
|
||||
// ---------------------------------------
|
||||
@Test
|
||||
void testCreateMessageWithoutInitialization() {
|
||||
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
var messages = List.of(new McpSchema.SamplingMessage(Role.USER, new McpSchema.TextContent("Test message")));
|
||||
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
|
||||
|
||||
var request = new CreateMessageRequest(messages, modelPrefs, null,
|
||||
CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized. Call the initialize method first!");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCreateMessageWithoutSamplingCapabilities(String clientType) {
|
||||
|
||||
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build();
|
||||
|
||||
InitializeResult initResult = client.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
var messages = List.of(new McpSchema.SamplingMessage(Role.USER, new McpSchema.TextContent("Test message")));
|
||||
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
|
||||
|
||||
var request = new CreateMessageRequest(messages, modelPrefs, null,
|
||||
CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be configured with sampling capabilities");
|
||||
});
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCreateMessageSuccess(String clientType) throws InterruptedException {
|
||||
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
|
||||
assertThat(request.messages()).hasSize(1);
|
||||
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
|
||||
|
||||
return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName",
|
||||
CreateMessageResult.StopReason.STOP_SEQUENCE);
|
||||
};
|
||||
|
||||
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().sampling().build())
|
||||
.sampling(samplingHandler)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = client.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
var messages = List.of(new McpSchema.SamplingMessage(Role.USER, new McpSchema.TextContent("Test message")));
|
||||
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
|
||||
|
||||
var request = new CreateMessageRequest(messages, modelPrefs, null,
|
||||
CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.createMessage(request)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.role()).isEqualTo(Role.USER);
|
||||
assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class);
|
||||
assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message");
|
||||
assertThat(result.model()).isEqualTo("MockModelName");
|
||||
assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE);
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Roots Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testRootsSuccess(String clientType) {
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
|
||||
|
||||
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(roots)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThat(rootsRef.get()).isNull();
|
||||
|
||||
assertThat(mcpServer.listRoots().roots()).containsAll(roots);
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(roots);
|
||||
});
|
||||
|
||||
// Remove a root
|
||||
mcpClient.removeRoot(roots.get(0).uri());
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
|
||||
});
|
||||
|
||||
// Add a new root
|
||||
var root3 = new Root("uri3://", "root3");
|
||||
mcpClient.addRoot(root3);
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testRootsWithoutCapability(String clientType) {
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
|
||||
}).build();
|
||||
|
||||
// Create client without roots capability
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No
|
||||
// roots
|
||||
// capability
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Attempt to list roots should fail
|
||||
assertThatThrownBy(() -> mcpServer.listRoots().roots()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Roots not supported");
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testRootsWithEmptyRootsList(String clientType) {
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(List.of()) // Empty roots list
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).isEmpty();
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testRootsWithMultipleConsumers(String clientType) {
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
List<Root> roots = List.of(new Root("uri1://", "root1"));
|
||||
|
||||
AtomicReference<List<Root>> rootsRef1 = new AtomicReference<>();
|
||||
AtomicReference<List<Root>> rootsRef2 = new AtomicReference<>();
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate))
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(roots)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef1.get()).containsAll(roots);
|
||||
assertThat(rootsRef2.get()).containsAll(roots);
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testRootsServerCloseWithActiveSubscription(String clientType) {
|
||||
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
List<Root> roots = List.of(new Root("uri1://", "root1"));
|
||||
|
||||
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(roots)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(roots);
|
||||
});
|
||||
|
||||
// Close server while subscription is active
|
||||
mcpServer.close();
|
||||
|
||||
// Verify client can handle server closure gracefully
|
||||
mcpClient.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// 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 = clientBulders.get(clientType);
|
||||
|
||||
var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
|
||||
new Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
return callResponse;
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool1)
|
||||
.build();
|
||||
|
||||
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);
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testToolListChangeHandlingSuccess(String clientType) {
|
||||
|
||||
var clientBuilder = clientBulders.get(clientType);
|
||||
|
||||
var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
|
||||
new Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
return callResponse;
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool1)
|
||||
.build();
|
||||
|
||||
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
|
||||
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
rootsRef.set(toolsUpdate);
|
||||
}).build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThat(rootsRef.get()).isNull();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
|
||||
|
||||
mcpServer.notifyToolsListChanged();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
|
||||
});
|
||||
|
||||
// Remove a tool
|
||||
mcpServer.removeTool("tool1");
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).isEmpty();
|
||||
});
|
||||
|
||||
// Add a new tool
|
||||
McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration(
|
||||
new Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
|
||||
|
||||
mcpServer.addTool(tool2);
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
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 org.springframework.web.servlet.function.ServerResponse.SseBuilder;
|
||||
|
||||
/**
|
||||
* Server-side implementation of the Model Context Protocol (MCP) transport layer using
|
||||
* HTTP with Server-Sent Events (SSE) through Spring WebMVC. This implementation provides
|
||||
* a bridge between synchronous WebMVC operations and reactive programming patterns to
|
||||
* maintain compatibility with the reactive transport interface.
|
||||
*
|
||||
* @deprecated This class will be removed in 0.9.0. Use
|
||||
* {@link WebMvcSseServerTransportProvider}.
|
||||
*
|
||||
* <p>
|
||||
* Key features:
|
||||
* <ul>
|
||||
* <li>Implements bidirectional communication using HTTP POST for client-to-server
|
||||
* messages and SSE for server-to-client messages</li>
|
||||
* <li>Manages client sessions with unique IDs for reliable message delivery</li>
|
||||
* <li>Supports graceful shutdown with proper session cleanup</li>
|
||||
* <li>Provides JSON-RPC message handling through configured endpoints</li>
|
||||
* <li>Includes built-in error handling and logging</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The transport operates on two main endpoints:
|
||||
* <ul>
|
||||
* <li>{@code /sse} - The SSE endpoint where clients establish their event stream
|
||||
* connection</li>
|
||||
* <li>A configurable message endpoint where clients send their JSON-RPC messages via HTTP
|
||||
* POST</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This implementation uses {@link ConcurrentHashMap} to safely manage multiple client
|
||||
* sessions in a thread-safe manner. Each client session is assigned a unique ID and
|
||||
* maintains its own SSE connection.
|
||||
* @author Christian Tzolov
|
||||
* @author Alexandros Pappas
|
||||
* @see ServerMcpTransport
|
||||
* @see RouterFunction
|
||||
*/
|
||||
@Deprecated
|
||||
public class WebMvcSseServerTransport implements ServerMcpTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebMvcSseServerTransport.class);
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages sent through the SSE connection.
|
||||
*/
|
||||
public static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/**
|
||||
* Event type for sending the message endpoint URI to clients.
|
||||
*/
|
||||
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path as specified by the MCP transport specification.
|
||||
*/
|
||||
public static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final String messageEndpoint;
|
||||
|
||||
private final String sseEndpoint;
|
||||
|
||||
private final RouterFunction<ServerResponse> routerFunction;
|
||||
|
||||
/**
|
||||
* Map of active client sessions, keyed by session ID.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, ClientSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Flag indicating if the transport is shutting down.
|
||||
*/
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/**
|
||||
* The function to process incoming JSON-RPC messages and produce responses.
|
||||
*/
|
||||
private Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> connectHandler;
|
||||
|
||||
/**
|
||||
* Constructs a new WebMvcSseServerTransport 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.
|
||||
* @throws IllegalArgumentException if either objectMapper or messageEndpoint is null
|
||||
*/
|
||||
public WebMvcSseServerTransport(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 WebMvcSseServerTransport instance with the default SSE endpoint.
|
||||
* @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.
|
||||
* @throws IllegalArgumentException if either objectMapper or messageEndpoint is null
|
||||
*/
|
||||
public WebMvcSseServerTransport(ObjectMapper objectMapper, String messageEndpoint) {
|
||||
this(objectMapper, messageEndpoint, DEFAULT_SSE_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the message handler for this transport. In the WebMVC SSE implementation,
|
||||
* this method only stores the handler for later use, as connections are initiated by
|
||||
* clients rather than the server.
|
||||
* @param connectionHandler The function to process incoming JSON-RPC messages and
|
||||
* produce responses
|
||||
* @return An empty Mono since the server doesn't initiate connections
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(
|
||||
Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> connectionHandler) {
|
||||
this.connectHandler = connectionHandler;
|
||||
// Server-side transport doesn't initiate connections
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a message to all connected clients through their SSE connections. The
|
||||
* message is serialized to JSON and sent as an SSE event with type "message". If any
|
||||
* errors occur during sending to a particular client, they are logged but don't
|
||||
* prevent sending to other clients.
|
||||
* @param message The JSON-RPC message to broadcast to all connected clients
|
||||
* @return A Mono that completes when the broadcast attempt is finished
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
if (sessions.isEmpty()) {
|
||||
logger.debug("No active sessions to broadcast message to");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String jsonText = objectMapper.writeValueAsString(message);
|
||||
logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
|
||||
|
||||
sessions.values().forEach(session -> {
|
||||
try {
|
||||
session.sseBuilder.id(session.id).event(MESSAGE_EVENT_TYPE).data(jsonText);
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to send message to session {}: {}", session.id, e.getMessage());
|
||||
session.sseBuilder.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (IOException e) {
|
||||
logger.error("Failed to serialize message: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles new SSE connection requests from clients by creating a new session and
|
||||
* establishing an SSE connection. This method:
|
||||
* <ul>
|
||||
* <li>Generates a unique session ID</li>
|
||||
* <li>Creates a new ClientSession with an SSE builder</li>
|
||||
* <li>Sends an initial endpoint event to inform the client where to send
|
||||
* messages</li>
|
||||
* <li>Maintains the session in the sessions map</li>
|
||||
* </ul>
|
||||
* @param request The incoming server request
|
||||
* @return A ServerResponse configured for SSE communication, or an error response if
|
||||
* the server is shutting down or the connection fails
|
||||
*/
|
||||
private ServerResponse handleSseConnection(ServerRequest request) {
|
||||
if (this.isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
|
||||
}
|
||||
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
logger.debug("Creating new SSE connection for session: {}", sessionId);
|
||||
|
||||
// Send initial endpoint event
|
||||
try {
|
||||
return ServerResponse.sse(sseBuilder -> {
|
||||
sseBuilder.onComplete(() -> {
|
||||
logger.debug("SSE connection completed for session: {}", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
});
|
||||
sseBuilder.onTimeout(() -> {
|
||||
logger.debug("SSE connection timed out for session: {}", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
});
|
||||
|
||||
ClientSession session = new ClientSession(sessionId, sseBuilder);
|
||||
this.sessions.put(sessionId, session);
|
||||
|
||||
try {
|
||||
session.sseBuilder.id(session.id).event(ENDPOINT_EVENT_TYPE).data(messageEndpoint);
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to poll event from session queue: {}", e.getMessage());
|
||||
sseBuilder.error(e);
|
||||
}
|
||||
}, Duration.ZERO);
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, e.getMessage());
|
||||
sessions.remove(sessionId);
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients. This method:
|
||||
* <ul>
|
||||
* <li>Deserializes the request body into a JSON-RPC message</li>
|
||||
* <li>Processes the message through the configured connect handler</li>
|
||||
* <li>Returns appropriate HTTP responses based on the processing result</li>
|
||||
* </ul>
|
||||
* @param request The incoming server request containing the JSON-RPC message
|
||||
* @return A ServerResponse indicating success (200 OK) or appropriate error status
|
||||
* with error details in case of failures
|
||||
*/
|
||||
private ServerResponse handleMessage(ServerRequest request) {
|
||||
if (this.isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
String body = request.body(String.class);
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
|
||||
|
||||
// Convert the message to a Mono, apply the handler, and block for the
|
||||
// response
|
||||
@SuppressWarnings("unused")
|
||||
McpSchema.JSONRPCMessage response = Mono.just(message).transform(connectHandler).block();
|
||||
|
||||
return ServerResponse.ok().build();
|
||||
}
|
||||
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("Error handling message: {}", e.getMessage());
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an active client session with its associated SSE connection. Each
|
||||
* session maintains:
|
||||
* <ul>
|
||||
* <li>A unique session identifier</li>
|
||||
* <li>An SSE builder for sending server events to the client</li>
|
||||
* <li>Logging of session lifecycle events</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static class ClientSession {
|
||||
|
||||
private final String id;
|
||||
|
||||
private final SseBuilder sseBuilder;
|
||||
|
||||
/**
|
||||
* Creates a new client session with the specified ID and SSE builder.
|
||||
* @param id The unique identifier for this session
|
||||
* @param sseBuilder The SSE builder for sending server events to the client
|
||||
*/
|
||||
ClientSession(String id, SseBuilder sseBuilder) {
|
||||
this.id = id;
|
||||
this.sseBuilder = sseBuilder;
|
||||
logger.debug("Session {} initialized with SSE emitter", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this session by completing the SSE connection. Any errors during
|
||||
* completion are logged but do not prevent the session from being marked as
|
||||
* closed.
|
||||
*/
|
||||
void close() {
|
||||
logger.debug("Closing session: {}", id);
|
||||
try {
|
||||
sseBuilder.complete();
|
||||
logger.debug("Successfully completed SSE emitter for session {}", id);
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.warn("Failed to complete SSE emitter for session {}: {}", id, e.getMessage());
|
||||
// sseBuilder.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts data from one type to another using the configured ObjectMapper. This is
|
||||
* particularly useful for handling complex JSON-RPC parameter types.
|
||||
* @param data The source data object to convert
|
||||
* @param typeRef The target type reference
|
||||
* @return The converted object of type T
|
||||
* @param <T> The target type
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return this.objectMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a graceful shutdown of the transport. This method:
|
||||
* <ul>
|
||||
* <li>Sets the closing flag to prevent new connections</li>
|
||||
* <li>Closes all active SSE connections</li>
|
||||
* <li>Removes all session records</li>
|
||||
* </ul>
|
||||
* @return A Mono that completes when all cleanup operations are finished
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> {
|
||||
this.isClosing = true;
|
||||
logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size());
|
||||
|
||||
sessions.values().forEach(session -> {
|
||||
String sessionId = session.id;
|
||||
session.close();
|
||||
sessions.remove(sessionId);
|
||||
});
|
||||
|
||||
logger.debug("Graceful shutdown completed");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the RouterFunction that defines the HTTP endpoints for this transport. The
|
||||
* router function handles two endpoints:
|
||||
* <ul>
|
||||
* <li>GET /sse - For establishing SSE connections</li>
|
||||
* <li>POST [messageEndpoint] - For receiving JSON-RPC messages from clients</li>
|
||||
* </ul>
|
||||
* @return The configured RouterFunction for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<ServerResponse> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransport;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
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;
|
||||
|
||||
@Deprecated
|
||||
@Timeout(15)
|
||||
class WebMvcSseAsyncServerTransportDeprecatedTests extends AbstractMcpAsyncServerDeprecatedTests {
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private static final int PORT = 8181;
|
||||
|
||||
private Tomcat tomcat;
|
||||
|
||||
private WebMvcSseServerTransport transport;
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcSseServerTransport webMvcSseServerTransport() {
|
||||
return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> routerFunction(WebMvcSseServerTransport transport) {
|
||||
return transport.getRouterFunction();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private AnnotationConfigWebApplicationContext appContext;
|
||||
|
||||
@Override
|
||||
protected ServerMcpTransport createMcpTransport() {
|
||||
// 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
|
||||
transport = appContext.getBean(WebMvcSseServerTransport.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);
|
||||
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 transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClose() {
|
||||
if (transport != null) {
|
||||
transport.closeGracefully().block();
|
||||
}
|
||||
if (appContext != null) {
|
||||
appContext.close();
|
||||
}
|
||||
if (tomcat != null) {
|
||||
try {
|
||||
tomcat.stop();
|
||||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
||||
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;
|
||||
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.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
@Deprecated
|
||||
public class WebMvcSseIntegrationDeprecatedTests {
|
||||
|
||||
private static final int PORT = 8183;
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private WebMvcSseServerTransport mcpServerTransport;
|
||||
|
||||
McpClient.SyncSpec clientBuilder;
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcSseServerTransport webMvcSseServerTransport() {
|
||||
return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> routerFunction(WebMvcSseServerTransport transport) {
|
||||
return transport.getRouterFunction();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private Tomcat tomcat;
|
||||
|
||||
private AnnotationConfigWebApplicationContext appContext;
|
||||
|
||||
@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
|
||||
mcpServerTransport = appContext.getBean(WebMvcSseServerTransport.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");
|
||||
|
||||
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);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start Tomcat", e);
|
||||
}
|
||||
|
||||
this.clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (mcpServerTransport != null) {
|
||||
mcpServerTransport.closeGracefully().block();
|
||||
}
|
||||
if (appContext != null) {
|
||||
appContext.close();
|
||||
}
|
||||
if (tomcat != null) {
|
||||
try {
|
||||
tomcat.stop();
|
||||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Sampling Tests
|
||||
// ---------------------------------------
|
||||
@Test
|
||||
void testCreateMessageWithoutInitialization() {
|
||||
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
var messages = List
|
||||
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
|
||||
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
|
||||
|
||||
var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null,
|
||||
McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized. Call the initialize method first!");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMessageWithoutSamplingCapabilities() {
|
||||
|
||||
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
|
||||
|
||||
InitializeResult initResult = client.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
var messages = List
|
||||
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
|
||||
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
|
||||
|
||||
var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null,
|
||||
McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be configured with sampling capabilities");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMessageSuccess() throws InterruptedException {
|
||||
|
||||
var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
|
||||
assertThat(request.messages()).hasSize(1);
|
||||
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
|
||||
|
||||
return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName",
|
||||
CreateMessageResult.StopReason.STOP_SEQUENCE);
|
||||
};
|
||||
|
||||
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().sampling().build())
|
||||
.sampling(samplingHandler)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = client.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
var messages = List
|
||||
.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
|
||||
var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0);
|
||||
|
||||
var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null,
|
||||
McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.createMessage(request)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.role()).isEqualTo(Role.USER);
|
||||
assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class);
|
||||
assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message");
|
||||
assertThat(result.model()).isEqualTo("MockModelName");
|
||||
assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE);
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Roots Tests
|
||||
// ---------------------------------------
|
||||
@Test
|
||||
void testRootsSuccess() {
|
||||
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
|
||||
|
||||
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(roots)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThat(rootsRef.get()).isNull();
|
||||
|
||||
assertThat(mcpServer.listRoots().roots()).containsAll(roots);
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(roots);
|
||||
});
|
||||
|
||||
// Remove a root
|
||||
mcpClient.removeRoot(roots.get(0).uri());
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
|
||||
});
|
||||
|
||||
// Add a new root
|
||||
var root3 = new Root("uri3://", "root3");
|
||||
mcpClient.addRoot(root3);
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsWithoutCapability() {
|
||||
var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> {
|
||||
}).build();
|
||||
|
||||
// Create client without roots capability
|
||||
// No roots capability
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Attempt to list roots should fail
|
||||
assertThatThrownBy(() -> mcpServer.listRoots().roots()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Roots not supported");
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsWithEmptyRootsList() {
|
||||
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(List.of()) // Empty roots list
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).isEmpty();
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsWithMultipleConsumers() {
|
||||
List<Root> roots = List.of(new Root("uri1://", "root1"));
|
||||
|
||||
AtomicReference<List<Root>> rootsRef1 = new AtomicReference<>();
|
||||
AtomicReference<List<Root>> rootsRef2 = new AtomicReference<>();
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate))
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(roots)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef1.get()).containsAll(roots);
|
||||
assertThat(rootsRef2.get()).containsAll(roots);
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsServerCloseWithActiveSubscription() {
|
||||
List<Root> roots = List.of(new Root("uri1://", "root1"));
|
||||
|
||||
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate))
|
||||
.build();
|
||||
|
||||
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.roots(roots)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.rootsListChangedNotification();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(roots);
|
||||
});
|
||||
|
||||
// Close server while subscription is active
|
||||
mcpServer.close();
|
||||
|
||||
// Verify client can handle server closure gracefully
|
||||
mcpClient.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tools Tests
|
||||
// ---------------------------------------
|
||||
|
||||
String emptyJsonSchema = """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void testToolCallSuccess() {
|
||||
|
||||
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
return callResponse;
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool1)
|
||||
.build();
|
||||
|
||||
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);
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testToolListChangeHandlingSuccess() {
|
||||
|
||||
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
return callResponse;
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport)
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool1)
|
||||
.build();
|
||||
|
||||
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
|
||||
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
rootsRef.set(toolsUpdate);
|
||||
}).build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThat(rootsRef.get()).isNull();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
|
||||
|
||||
mcpServer.notifyToolsListChanged();
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
|
||||
});
|
||||
|
||||
// Remove a tool
|
||||
mcpServer.removeTool("tool1");
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).isEmpty();
|
||||
});
|
||||
|
||||
// Add a new tool
|
||||
McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration(
|
||||
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
|
||||
|
||||
mcpServer.addTool(tool2);
|
||||
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
|
||||
});
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInitialize() {
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransport).build();
|
||||
|
||||
var mcpClient = clientBuilder.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
mcpClient.close();
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransport;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
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;
|
||||
|
||||
@Deprecated
|
||||
@Timeout(15)
|
||||
class WebMvcSseSyncServerTransportDeprecatedTests extends AbstractMcpSyncServerDeprecatedTests {
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private static final int PORT = 8181;
|
||||
|
||||
private Tomcat tomcat;
|
||||
|
||||
private WebMvcSseServerTransport transport;
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcSseServerTransport webMvcSseServerTransport() {
|
||||
return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> routerFunction(WebMvcSseServerTransport transport) {
|
||||
return transport.getRouterFunction();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private AnnotationConfigWebApplicationContext appContext;
|
||||
|
||||
@Override
|
||||
protected ServerMcpTransport createMcpTransport() {
|
||||
// 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
|
||||
transport = appContext.getBean(WebMvcSseServerTransport.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);
|
||||
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 transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClose() {
|
||||
if (transport != null) {
|
||||
transport.closeGracefully().block();
|
||||
}
|
||||
if (appContext != null) {
|
||||
appContext.close();
|
||||
}
|
||||
if (tomcat != null) {
|
||||
try {
|
||||
tomcat.stop();
|
||||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue