diff --git a/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml b/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml index 92812df..5b08c73 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml +++ b/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml @@ -153,6 +153,10 @@ class StdioServerTransport implements ServerMcpTransport { } +class HttpServletSseServerTransport implements ServerMcpTransport { +} + + class HttpClientSseClientTransport implements ClientMcpTransport { } diff --git a/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg b/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg index 1843faa..f83a586 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg +++ b/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg @@ -1 +1 @@ -McpTransportMono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler)Mono<Void> sendMessage(JSONRPCMessage message)void close()Mono<Void> closeGracefully()<T> T unmarshalFrom(Object data, TypeReference<T> typeRef)McpSession<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef)Mono<Void> sendNotification(String method, Map<String, Object> params)Mono<Void> closeGracefully()void close()DefaultMcpSessioninterface RequestHandlerinterface NotificationHandlerMcpClientBuilder using(ClientMcpTransport transport)McpAsyncClientMono<InitializeResult> initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully()Mono<Object> ping()Mono<Void> addRoot(Root root)Mono<Void> removeRoot(String rootUri)Mono<Void> rootsListChangedNotification()Mono<CallToolResult> callTool(CallToolRequest request)Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource(ReadResourceRequest request)Mono<ListResourceTemplatesResult> listResourceTemplates()Mono<Void> subscribeResource(SubscribeRequest request)Mono<Void> unsubscribeResource(UnsubscribeRequest request)Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt(GetPromptRequest request)Mono<Void> setLoggingLevel(LoggingLevel level)McpSyncClientInitializeResult initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()boolean closeGracefully()Object ping()void addRoot(Root root)void removeRoot(String rootUri)void rootsListChangedNotification()CallToolResult callTool(CallToolRequest request)ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource(ReadResourceRequest request)ListResourceTemplatesResult listResourceTemplates()void subscribeResource(SubscribeRequest request)void unsubscribeResource(UnsubscribeRequest request)ListPromptsResult listPrompts()GetPromptResult getPrompt(GetPromptRequest request)void setLoggingLevel(LoggingLevel level)McpServerBuilder using(ServerMcpTransport transport)McpAsyncServerServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully() Mono<Void> addTool(ToolRegistration toolRegistration)Mono<Void> removeTool(String toolName)Mono<Void> notifyToolsListChanged() Mono<Void> addResource(ResourceRegistration resourceHandler)Mono<Void> removeResource(String resourceUri)Mono<Void> notifyResourcesListChanged() Mono<Void> addPrompt(PromptRegistration promptRegistration)Mono<Void> removePrompt(String promptName)Mono<Void> notifyPromptsListChanged() Mono<Void> loggingNotification(LoggingMessageNotification notification) Mono<CreateMessageResult> createMessage(CreateMessageRequest request)McpSyncServerMcpAsyncServer getAsyncServer() ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()void closeGracefully() void addTool(ToolRegistration toolHandler)void removeTool(String toolName)void notifyToolsListChanged() void addResource(ResourceRegistration resourceHandler)void removeResource(String resourceUri)void notifyResourcesListChanged() void addPrompt(PromptRegistration promptRegistration)void removePrompt(String promptName)void notifyPromptsListChanged() void loggingNotification(LoggingMessageNotification notification) CreateMessageResult createMessage(CreateMessageRequest request)StdioClientTransportvoid setErrorHandler(Consumer<String> errorHandler)Sinks.Many<String> getErrorSink()ClientMcpTransportStdioServerTransportServerMcpTransportHttpClientSseClientTransportWebFluxSseClientTransportWebFluxSseServerTransportRouterFunction<?> getRouterFunction()WebMvcSseServerTransportRouterFunction<?> getRouterFunction()McpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface Contentinterface ServerCapabilitiesJSONRPCMessage deserializeJsonRpcMessage()McpErrorcreatescreatesdelegates tocreatescreatesusesthrows \ No newline at end of file +McpTransportMono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler)Mono<Void> sendMessage(JSONRPCMessage message)void close()Mono<Void> closeGracefully()<T> T unmarshalFrom(Object data, TypeReference<T> typeRef)McpSession<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef)Mono<Void> sendNotification(String method, Map<String, Object> params)Mono<Void> closeGracefully()void close()DefaultMcpSessioninterface RequestHandlerinterface NotificationHandlerMcpClientBuilder using(ClientMcpTransport transport)McpAsyncClientMono<InitializeResult> initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully()Mono<Object> ping()Mono<Void> addRoot(Root root)Mono<Void> removeRoot(String rootUri)Mono<Void> rootsListChangedNotification()Mono<CallToolResult> callTool(CallToolRequest request)Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource(ReadResourceRequest request)Mono<ListResourceTemplatesResult> listResourceTemplates()Mono<Void> subscribeResource(SubscribeRequest request)Mono<Void> unsubscribeResource(UnsubscribeRequest request)Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt(GetPromptRequest request)Mono<Void> setLoggingLevel(LoggingLevel level)McpSyncClientInitializeResult initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()boolean closeGracefully()Object ping()void addRoot(Root root)void removeRoot(String rootUri)void rootsListChangedNotification()CallToolResult callTool(CallToolRequest request)ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource(ReadResourceRequest request)ListResourceTemplatesResult listResourceTemplates()void subscribeResource(SubscribeRequest request)void unsubscribeResource(UnsubscribeRequest request)ListPromptsResult listPrompts()GetPromptResult getPrompt(GetPromptRequest request)void setLoggingLevel(LoggingLevel level)McpServerBuilder using(ServerMcpTransport transport)McpAsyncServerServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully() Mono<Void> addTool(ToolRegistration toolRegistration)Mono<Void> removeTool(String toolName)Mono<Void> notifyToolsListChanged() Mono<Void> addResource(ResourceRegistration resourceHandler)Mono<Void> removeResource(String resourceUri)Mono<Void> notifyResourcesListChanged() Mono<Void> addPrompt(PromptRegistration promptRegistration)Mono<Void> removePrompt(String promptName)Mono<Void> notifyPromptsListChanged() Mono<Void> loggingNotification(LoggingMessageNotification notification) Mono<CreateMessageResult> createMessage(CreateMessageRequest request)McpSyncServerMcpAsyncServer getAsyncServer() ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()void closeGracefully() void addTool(ToolRegistration toolHandler)void removeTool(String toolName)void notifyToolsListChanged() void addResource(ResourceRegistration resourceHandler)void removeResource(String resourceUri)void notifyResourcesListChanged() void addPrompt(PromptRegistration promptRegistration)void removePrompt(String promptName)void notifyPromptsListChanged() void loggingNotification(LoggingMessageNotification notification) CreateMessageResult createMessage(CreateMessageRequest request)StdioClientTransportvoid setErrorHandler(Consumer<String> errorHandler)Sinks.Many<String> getErrorSink()ClientMcpTransportStdioServerTransportServerMcpTransportHttpServletSseServerTransportHttpClientSseClientTransportWebFluxSseClientTransportWebFluxSseServerTransportRouterFunction<?> getRouterFunction()WebMvcSseServerTransportRouterFunction<?> getRouterFunction()McpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface Contentinterface ServerCapabilitiesJSONRPCMessage deserializeJsonRpcMessage()McpErrorcreatescreatesdelegates tocreatescreatesusesthrows \ No newline at end of file diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc index b1a98cc..c743410 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc @@ -15,7 +15,8 @@ Java SDK implementation of the link:https://modelcontextprotocol.io/introduction * Multiple transport implementations: ** Core transports: *** Stdio-based transport for process-based communication -*** Java HttpClient-based SSE transport for HTTP streaming +*** Java HttpClient-based SSE client transport for HTTP SSE Client-side streaming +*** Servlet-based SSE server transport for HTTP SSE Server streaming ** Spring-based transports: *** WebFlux SSE transport for reactive HTTP streaming *** WebMVC SSE transport for servlet-based HTTP streaming @@ -515,6 +516,41 @@ Implements the MCP HTTP with SSE transport specification, providing: * Message routing and session management * Graceful shutdown capabilities +SSE (Servlet):: ++ +Creates a Servlet-based SSE server transport. +Included in the core `mcp` module. +The `HttpServletSseServerTransport` can be used with any Servlet container. +To using it with a Spring Web application, you can register it as a Servlet bean: ++ +[source,java] +---- +@Configuration +@EnableWebMvc +public class McpServerConfig implements WebMvcConfigurer { + + @Bean + public HttpServletSseServerTransport servletSseServerTransport() { + return new HttpServletSseServerTransport(new ObjectMapper(), "/mcp/message"); + } + + @Bean + public ServletRegistrationBean customServletBean(HttpServletSseServerTransport servlet) { + return new ServletRegistrationBean(servlet); + } +} +---- ++ +Implements the MCP HTTP with SSE transport specification using the traditional Servlet API, providing: ++ +* Asynchronous message handling using Servlet 6.0 async support +* Session management for multiple client connections +* Two types of endpoints: +** SSE endpoint (/sse) for server-to-client events +** Message endpoint (configurable) for client-to-server requests +* Error handling and response formatting +* Graceful shutdown support + ====== === Server Capabilities diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc index b6a0cda..34bc9b9 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc @@ -20,7 +20,8 @@ Core implementation of the Model Context Protocol specification, providing: * *Core Transports* ** Stdio-based (`StdioClientTransport`, `StdioServerTransport`) for process-based communication -** Java HttpClient-based SSE client (`HttpClientSseClientTransport`) for HTTP streaming +** Java HttpClient-based SSE client (`HttpClientSseClientTransport`) for HTTP SSE Client-side streaming +** Servlet-based SSE server (`HttpServletSseServerTransport`) for HTTP SSE Server streaming using traditional Servlet API * *Optional SSE Transports* ** link:https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webflux-sse-transport[WebFlux SSE Transport] - Reactive HTTP streaming with Spring WebFlux (Client & Server) @@ -114,6 +115,7 @@ Reffer to the xref:dependency-management.adoc[Dependency Management] page for mo * link:https://github.com/habuma/spring-ai-examples/tree/main/spring-ai-mcp[Theme Park API] - MCP server/client with Theme Park API tools * link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/mcp-webflux-server[WebFlux SSE] - WebFlux server/client implementation * link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/mcp-webmvc-server[WebMvc SSE] - WebMvc server with HttpClient implementation +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/mcp-servlet-server[Servlet SSE] - SSE Servlet server with HttpClient implementation == Documentation diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index f3aa5db..9da326a 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -72,7 +72,7 @@ org.awaitility awaitility - 4.2.0 + ${awaitility.version} @@ -80,21 +80,6 @@ logback-classic ${logback.version} - - - diff --git a/mcp-transport/mcp-webflux-sse-transport/pom.xml b/mcp-transport/mcp-webflux-sse-transport/pom.xml index 1dae71c..dfb94e6 100644 --- a/mcp-transport/mcp-webflux-sse-transport/pom.xml +++ b/mcp-transport/mcp-webflux-sse-transport/pom.xml @@ -97,7 +97,7 @@ org.awaitility awaitility - 4.2.0 + ${awaitility.version} test diff --git a/mcp-transport/mcp-webmvc-sse-transport/pom.xml b/mcp-transport/mcp-webmvc-sse-transport/pom.xml index c612a4c..7833d52 100644 --- a/mcp-transport/mcp-webmvc-sse-transport/pom.xml +++ b/mcp-transport/mcp-webmvc-sse-transport/pom.xml @@ -87,7 +87,7 @@ org.awaitility awaitility - 4.2.0 + ${awaitility.version} test @@ -111,14 +111,14 @@ jakarta.servlet jakarta.servlet-api - 5.0.0 + ${jakarta.servlet.version} provided org.apache.tomcat.embed tomcat-embed-core - 11.0.2 + ${tomcat.version} test diff --git a/mcp/pom.xml b/mcp/pom.xml index 86d4ff4..2f8272f 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -103,7 +103,7 @@ org.awaitility awaitility - 4.2.0 + ${awaitility.version} test @@ -114,7 +114,29 @@ test + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + provided + + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + test + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + test + + - \ No newline at end of file + diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/_HttpServletSseServerTransport.java_ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransport.java similarity index 65% rename from mcp/src/test/java/org/springframework/ai/mcp/attic/_HttpServletSseServerTransport.java_ rename to mcp/src/main/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransport.java index 3bededd..4857a60 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/attic/_HttpServletSseServerTransport.java_ +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransport.java @@ -41,40 +41,84 @@ import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.ServerMcpTransport; /** - * A Servlet-based implementation of the MCP HTTP with SSE transport specification. This - * implementation provides similar functionality to FunctionalSseServerTransport but uses - * the traditional Servlet API instead of WebFlux. + * A Servlet-based implementation of the MCP HTTP with Server-Sent Events (SSE) transport + * specification. This implementation provides similar functionality to + * WebFluxSseServerTransport but uses the traditional Servlet API instead of WebFlux. + * + *

+ * The transport handles two types of endpoints: + *

+ * + *

+ * Features: + *

* * @author Christian Tzolov - * @since 1.0.0 + * @see ServerMcpTransport + * @see HttpServlet */ @WebServlet(asyncSupported = true) public class HttpServletSseServerTransport extends HttpServlet implements ServerMcpTransport { + /** Logger for this class */ private static final Logger logger = LoggerFactory.getLogger(HttpServletSseServerTransport.class); + /** The endpoint path for SSE connections */ public static final String SSE_ENDPOINT = "/sse"; + /** Event type for regular messages */ public static final String MESSAGE_EVENT_TYPE = "message"; + /** Event type for endpoint information */ public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + /** JSON object mapper for serialization/deserialization */ private final ObjectMapper objectMapper; + /** The endpoint path for handling client messages */ private final String messageEndpoint; + /** Map of active client sessions, keyed by session ID */ private final Map sessions = new ConcurrentHashMap<>(); + /** Flag indicating if the transport is in the process of shutting down */ private final AtomicBoolean isClosing = new AtomicBoolean(false); + /** Handler for processing incoming messages */ private Function, Mono> connectHandler; + /** + * Creates a new HttpServletSseServerTransport instance. + * @param objectMapper The JSON object mapper to use for message + * serialization/deserialization + * @param messageEndpoint The endpoint path where clients will send their messages + */ public HttpServletSseServerTransport(ObjectMapper objectMapper, String messageEndpoint) { this.objectMapper = objectMapper; this.messageEndpoint = messageEndpoint; } + /** + * Handles GET requests to establish SSE connections. + *

+ * This method sets up a new SSE connection when a client connects to the SSE + * endpoint. It configures the response headers for SSE, creates a new session, and + * sends the initial endpoint information to the client. + * @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 { @@ -102,12 +146,23 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server PrintWriter writer = response.getWriter(); ClientSession session = new ClientSession(sessionId, asyncContext, writer); - sessions.put(sessionId, session); + this.sessions.put(sessionId, session); // Send initial endpoint event - sendEvent(writer, ENDPOINT_EVENT_TYPE, messageEndpoint); + this.sendEvent(writer, ENDPOINT_EVENT_TYPE, messageEndpoint); } + /** + * Handles POST requests for client messages. + *

+ * This method processes incoming messages from clients, routes them through the + * connect handler if configured, and sends back the appropriate response. It handles + * error cases and formats error responses according to the MCP specification. + * @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 doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { @@ -200,12 +255,25 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server } } + /** + * Sets up the message handler for processing client requests. + * @param handler The function to process incoming messages and produce responses + * @return A Mono that completes when the handler is set up + */ @Override public Mono connect(Function, Mono> handler) { this.connectHandler = handler; return Mono.empty(); } + /** + * Broadcasts a message to all connected clients. + *

+ * This method serializes the message and sends it to all active client sessions. If a + * client is disconnected, its session is removed. + * @param message The message to broadcast + * @return A Mono that completes when the message has been sent to all clients + */ @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { if (sessions.isEmpty()) { @@ -219,7 +287,7 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server sessions.values().forEach(session -> { try { - sendEvent(session.writer, MESSAGE_EVENT_TYPE, jsonText); + this.sendEvent(session.writer, MESSAGE_EVENT_TYPE, jsonText); } catch (IOException e) { logger.error("Failed to send message to session {}: {}", session.id, e.getMessage()); @@ -236,16 +304,35 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server }); } + /** + * Closes the transport. + *

+ * This implementation delegates to the super class's close method. + */ @Override public void close() { ServerMcpTransport.super.close(); } + /** + * Unmarshals data from one type to another using the object mapper. + * @param The target type + * @param data The source data + * @param typeRef The type reference for the target type + * @return The unmarshaled data + */ @Override public T unmarshalFrom(Object data, TypeReference typeRef) { return objectMapper.convertValue(data, typeRef); } + /** + * Initiates a graceful shutdown of the transport. + *

+ * This method marks the transport as closing and closes all active client sessions. + * New connection attempts will be rejected during shutdown. + * @return A Mono that completes when all sessions have been closed + */ @Override public Mono closeGracefully() { isClosing.set(true); @@ -257,6 +344,13 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server }); } + /** + * Sends an SSE event to a client. + * @param writer The writer to send the event through + * @param eventType The type of event (message or endpoint) + * @param data The event data + * @throws IOException If an error occurs while writing the event + */ private void sendEvent(PrintWriter writer, String eventType, String data) throws IOException { writer.write("event: " + eventType + "\n"); writer.write("data: " + data + "\n\n"); @@ -267,11 +361,21 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server } } + /** + * Removes a client session and completes its async context. + * @param session The session to remove + */ private void removeSession(ClientSession session) { sessions.remove(session.id); session.asyncContext.complete(); } + /** + * Represents a client connection session. + *

+ * This class holds the necessary information about a client's SSE connection, + * including its ID, async context, and output writer. + */ private static class ClientSession { private final String id; @@ -288,6 +392,12 @@ public class HttpServletSseServerTransport extends HttpServlet implements Server } + /** + * Cleans up resources when the servlet is being destroyed. + *

+ * This method ensures a graceful shutdown by closing all client connections before + * calling the parent's destroy method. + */ @Override public void destroy() { closeGracefully().block(); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/_SseAsyncIntegrationTests.java_ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java similarity index 62% rename from mcp/src/test/java/org/springframework/ai/mcp/server/_SseAsyncIntegrationTests.java_ rename to mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java index babba8d..71338a9 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/_SseAsyncIntegrationTests.java_ +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.ai.mcp.server; +package org.springframework.ai.mcp.server.transport; import java.time.Duration; import java.util.List; @@ -22,17 +22,21 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import com.fasterxml.jackson.databind.ObjectMapper; +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.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.test.StepVerifier; import org.springframework.ai.mcp.client.McpClient; -import org.springframework.ai.mcp.client.transport.SseClientTransport; +import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport; +import org.springframework.ai.mcp.server.McpServer; import org.springframework.ai.mcp.server.McpServer.ToolRegistration; -import org.springframework.ai.mcp.server.transport.SseServerTransport; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; @@ -44,50 +48,76 @@ import org.springframework.ai.mcp.spec.McpSchema.Role; import org.springframework.ai.mcp.spec.McpSchema.Root; import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities; import org.springframework.ai.mcp.spec.McpSchema.Tool; -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 SseAsyncIntegrationTests { +public class HttpServletSseServerTransportIntegrationTests { - private static final int PORT = 8181; + private static final Logger logger = LoggerFactory.getLogger(HttpServletSseServerTransportIntegrationTests.class); + + private static final int PORT = 8184; private static final String MESSAGE_ENDPOINT = "/mcp/message"; - private DisposableServer httpServer; - - private SseServerTransport mcpServerTransport; + private HttpServletSseServerTransport mcpServerTransport; McpClient.Builder clientBuilder; + private Tomcat tomcat; + @BeforeEach public void before() { - this.mcpServerTransport = new SseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + tomcat = new Tomcat(); + tomcat.setPort(PORT); - HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction()); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); + String baseDir = System.getProperty("java.io.tmpdir"); + tomcat.setBaseDir(baseDir); - this.clientBuilder = McpClient - .using(new SseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT))); + Context context = tomcat.addContext("", baseDir); + + // Create and configure the transport + mcpServerTransport = new HttpServletSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + + // Add transport servlet to Tomcat + org.apache.catalina.Wrapper wrapper = context.createWrapper(); + wrapper.setName("mcpServlet"); + wrapper.setServlet(mcpServerTransport); + wrapper.setLoadOnStartup(1); + wrapper.setAsyncSupported(true); + context.addChild(wrapper); + context.addServletMappingDecoded("/*", "mcpServlet"); + + try { + var connector = tomcat.getConnector(); + connector.setAsyncTimeout(3000); + tomcat.start(); + assertThat(tomcat.getServer().getState() == LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + + this.clientBuilder = McpClient.using(new HttpClientSseClientTransport("http://localhost:" + PORT)); } @AfterEach public void after() { - if (httpServer != null) { - httpServer.disposeNow(); + if (mcpServerTransport != null) { + mcpServerTransport.closeGracefully().block(); + } + 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.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); @@ -107,7 +137,6 @@ public class SseAsyncIntegrationTests { @Test void testCreateMessageWithoutSamplingCapabilities() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).sync(); @@ -129,8 +158,7 @@ public class SseAsyncIntegrationTests { } @Test - void testCreateMessageSuccess() throws InterruptedException { - + void testCreateMessageSuccess() { var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); Function samplingHandler = request -> { @@ -166,9 +194,6 @@ public class SseAsyncIntegrationTests { }).verifyComplete(); } - // --------------------------------------- - // Roots Tests - // --------------------------------------- @Test void testRootsSuccess() { List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); @@ -195,150 +220,23 @@ public class SseAsyncIntegrationTests { 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.using(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { - }).sync(); - - // Create client without roots capability - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No - // roots - // capability - .sync(); - - 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> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); - - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(List.of()) // Empty roots list - .sync(); - - 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 roots = List.of(new Root("uri1://", "root1")); - - AtomicReference> rootsRef1 = new AtomicReference<>(); - AtomicReference> rootsRef2 = new AtomicReference<>(); - - var mcpServer = McpServer.using(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate)) - .rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate)) - .sync(); - - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .sync(); - - 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 roots = List.of(new Root("uri1://", "root1")); - - AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); - - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .sync(); - - 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": {} + "$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); ToolRegistration tool1 = new ToolRegistration(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/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") @@ -371,11 +269,9 @@ public class SseAsyncIntegrationTests { @Test void testToolListChangeHandlingSuccess() { - var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); ToolRegistration tool1 = new ToolRegistration(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/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") @@ -390,50 +286,59 @@ public class SseAsyncIntegrationTests { .tools(tool1) .sync(); - AtomicReference> rootsRef = new AtomicReference<>(); + AtomicReference> toolsRef = new AtomicReference<>(); var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { - // perform a blocking call to a remote service String response = RestClient.create() .get() .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") .retrieve() .body(String.class); assertThat(response).isNotBlank(); - rootsRef.set(toolsUpdate); + toolsRef.set(toolsUpdate); }).sync(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - assertThat(rootsRef.get()).isNull(); + assertThat(toolsRef.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())); + assertThat(toolsRef.get()).containsAll(List.of(tool1.tool())); }); - // Remove a tool mcpServer.removeTool("tool1"); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).isEmpty(); + assertThat(toolsRef.get()).isEmpty(); }); - // Add a new tool ToolRegistration tool2 = new ToolRegistration(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())); + assertThat(toolsRef.get()).containsAll(List.of(tool2.tool())); }); mcpClient.close(); mcpServer.close(); } + @Test + void testInitialize() { + var mcpServer = McpServer.using(mcpServerTransport).sync(); + var mcpClient = clientBuilder.sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.close(); + mcpServer.close(); + } + } diff --git a/pom.xml b/pom.xml index 81d7a14..ea037cf 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,9 @@ 0.0.4 1.6.2 5.10.5 + 11.0.2 + 6.1.0 + 4.2.0