- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp
- 0.6.0
+ 0.8.0
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp-test
- 0.6.0
+ 0.8.0
test
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java
similarity index 90%
rename from mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransport.java
rename to mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java
index a4605c657..b0dfa89c0 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransport.java
+++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java
@@ -1,19 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client.transport;
+package io.modelcontextprotocol.client.transport;
import java.io.IOException;
import java.util.function.BiConsumer;
@@ -21,6 +9,11 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
+import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.Disposable;
@@ -32,11 +25,6 @@
import reactor.util.retry.Retry;
import reactor.util.retry.Retry.RetrySignal;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage;
-import org.springframework.ai.mcp.util.Assert;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
@@ -44,7 +32,7 @@
/**
* Server-Sent Events (SSE) implementation of the
- * {@link org.springframework.ai.mcp.spec.McpTransport} that follows the MCP HTTP with SSE
+ * {@link io.modelcontextprotocol.spec.McpTransport} that follows the MCP HTTP with SSE
* transport specification.
*
*
@@ -70,7 +58,7 @@
* "https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse">MCP
* HTTP with SSE Transport Specification
*/
-public class WebFluxSseClientTransport implements ClientMcpTransport {
+public class WebFluxSseClientTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(WebFluxSseClientTransport.class);
@@ -315,7 +303,7 @@ public Mono closeGracefully() { // @formatter:off
} // @formatter:on
/**
- * Unmarshals data from a generic Object into the specified type using the configured
+ * Unmarshalls data from a generic Object into the specified type using the configured
* ObjectMapper.
*
*
@@ -325,7 +313,7 @@ public Mono closeGracefully() { // @formatter:off
* @param the target type to convert the data into
* @param data the source object to convert
* @param typeRef the TypeReference describing the target type
- * @return the unmarshaled object of type T
+ * @return the unmarshalled object of type T
* @throws IllegalArgumentException if the conversion cannot be performed
*/
@Override
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebFluxSseServerTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java
similarity index 94%
rename from mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebFluxSseServerTransport.java
rename to mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java
index 6126eba58..fb0b581e0 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebFluxSseServerTransport.java
+++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java
@@ -1,4 +1,4 @@
-package org.springframework.ai.mcp.server.transport;
+package io.modelcontextprotocol.server.transport;
import java.io.IOException;
import java.time.Duration;
@@ -9,16 +9,16 @@
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.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
-import org.springframework.ai.mcp.util.Assert;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
@@ -60,7 +60,10 @@
* @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);
@@ -182,16 +185,16 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
try {// @formatter:off
String jsonText = objectMapper.writeValueAsString(message);
ServerSentEvent event = ServerSentEvent.builder()
- .event(MESSAGE_EVENT_TYPE)
- .data(jsonText)
- .build();
+ .event(MESSAGE_EVENT_TYPE)
+ .data(jsonText)
+ .build();
logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
List failedSessions = sessions.values().stream()
- .filter(session -> session.messageSink.tryEmitNext(event).isFailure())
- .map(session -> session.id)
- .toList();
+ .filter(session -> session.messageSink.tryEmitNext(event).isFailure())
+ .map(session -> session.id)
+ .toList();
if (failedSessions.isEmpty()) {
logger.debug("Successfully broadcast message to all sessions");
@@ -251,7 +254,7 @@ public Mono closeGracefully() {
.then(Mono.fromRunnable(() -> sessions.remove(sessionId)));
}).toList()))
.timeout(Duration.ofSeconds(5))
- .doOnSuccess(v -> logger.info("Graceful shutdown completed"))
+ .doOnSuccess(v -> logger.debug("Graceful shutdown completed"))
.doOnError(e -> logger.error("Error during graceful shutdown: {}", e.getMessage()));
}
@@ -407,4 +410,4 @@ void close() {
}
-}
+}
\ No newline at end of file
diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java
new file mode 100644
index 000000000..cf3eeae03
--- /dev/null
+++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java
@@ -0,0 +1,351 @@
+package io.modelcontextprotocol.server.transport;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+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.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;
+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;
+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.
+ *
+ *
+ * Key features:
+ *
+ * Implements the {@link McpServerTransportProvider} interface that allows managing
+ * {@link McpServerSession} instances and enabling their communication with the
+ * {@link McpServerTransport} abstraction.
+ * Uses WebFlux for non-blocking request handling and SSE support
+ * Maintains client sessions for reliable message delivery
+ * Supports graceful shutdown with session cleanup
+ * Thread-safe message broadcasting to multiple clients
+ *
+ *
+ *
+ * The transport sets up two main endpoints:
+ *
+ * SSE endpoint (/sse) - For establishing SSE connections with clients
+ * Message endpoint (configurable) - For receiving JSON-RPC messages from clients
+ *
+ *
+ *
+ * This implementation is thread-safe and can handle multiple concurrent client
+ * connections. It uses {@link ConcurrentHashMap} for session management and Project
+ * Reactor's non-blocking APIs for message processing and delivery.
+ *
+ * @author Christian Tzolov
+ * @author Alexandros Pappas
+ * @author Dariusz Jędrzejczyk
+ * @see McpServerTransport
+ * @see ServerSentEvent
+ */
+public class WebFluxSseServerTransportProvider implements McpServerTransportProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(WebFluxSseServerTransportProvider.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;
+
+ private McpServerSession.Factory sessionFactory;
+
+ /**
+ * Map of active client sessions, keyed by session ID.
+ */
+ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+
+ /**
+ * Flag indicating if the transport is shutting down.
+ */
+ private volatile boolean isClosing = false;
+
+ /**
+ * Constructs a new WebFlux SSE server transport provider 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 WebFluxSseServerTransportProvider(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 provider 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 WebFluxSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint) {
+ this(objectMapper, messageEndpoint, DEFAULT_SSE_ENDPOINT);
+ }
+
+ @Override
+ public void setSessionFactory(McpServerSession.Factory sessionFactory) {
+ this.sessionFactory = sessionFactory;
+ }
+
+ /**
+ * 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.
+ *
+ *
+ * The method:
+ *
+ * Serializes the message to JSON
+ * Creates a server-sent event with the message data
+ * Attempts to send the event to all active sessions
+ * Tracks and reports any delivery failures
+ *
+ * @param method The JSON-RPC method to send to clients
+ * @param params The method parameters to send to clients
+ * @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 notifyClients(String method, Map params) {
+ if (sessions.isEmpty()) {
+ logger.debug("No active sessions to broadcast message to");
+ return Mono.empty();
+ }
+
+ logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
+
+ return Flux.fromStream(sessions.values().stream())
+ .flatMap(session -> session.sendNotification(method, params)
+ .doOnError(e -> logger.error("Failed to " + "send message to session " + "{}: {}", session.getId(),
+ e.getMessage()))
+ .onErrorComplete())
+ .then();
+ }
+
+ // FIXME: This javadoc makes claims about using isClosing flag but it's not actually
+ // doing that.
+ /**
+ * Initiates a graceful shutdown of all the sessions. This method ensures all active
+ * sessions are properly closed and cleaned up.
+ *
+ *
+ * The shutdown process:
+ *
+ * Marks the transport as closing to prevent new connections
+ * Closes each active session
+ * Removes closed sessions from the sessions map
+ * Times out after 5 seconds if shutdown takes too long
+ *
+ * @return A Mono that completes when all sessions have been closed
+ */
+ @Override
+ public Mono closeGracefully() {
+ return Flux.fromIterable(sessions.values())
+ .doFirst(() -> logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()))
+ .flatMap(McpServerSession::closeGracefully)
+ .then();
+ }
+
+ /**
+ * Returns the WebFlux router function that defines the transport's HTTP endpoints.
+ * This router function should be integrated into the application's web configuration.
+ *
+ *
+ * The router function defines two endpoints:
+ *
+ * GET {sseEndpoint} - For establishing SSE connections
+ * POST {messageEndpoint} - For receiving client messages
+ *
+ * @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.
+ * @param request The incoming server request
+ * @return A Mono which emits a response with the SSE event stream
+ */
+ private Mono handleSseConnection(ServerRequest request) {
+ if (isClosing) {
+ return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
+ }
+
+ return ServerResponse.ok()
+ .contentType(MediaType.TEXT_EVENT_STREAM)
+ .body(Flux.>create(sink -> {
+ WebFluxMcpSessionTransport sessionTransport = new WebFluxMcpSessionTransport(sink);
+
+ McpServerSession session = sessionFactory.create(sessionTransport);
+ String sessionId = session.getId();
+
+ logger.debug("Created new SSE connection for session: {}", sessionId);
+ sessions.put(sessionId, session);
+
+ // Send initial endpoint event
+ logger.debug("Sending initial endpoint event to session: {}", sessionId);
+ sink.next(ServerSentEvent.builder()
+ .event(ENDPOINT_EVENT_TYPE)
+ .data(messageEndpoint + "?sessionId=" + sessionId)
+ .build());
+ 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.
+ *
+ *
+ * The handler:
+ *
+ * Deserializes the incoming JSON-RPC message
+ * Passes it through the message handler chain
+ * Returns appropriate HTTP responses based on processing results
+ * Handles various error conditions with appropriate error responses
+ *
+ * @param request The incoming server request containing the JSON-RPC message
+ * @return A Mono emitting the response indicating the message processing result
+ */
+ private Mono handleMessage(ServerRequest request) {
+ if (isClosing) {
+ return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
+ }
+
+ if (request.queryParam("sessionId").isEmpty()) {
+ return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing in message endpoint"));
+ }
+
+ McpServerSession session = sessions.get(request.queryParam("sessionId").get());
+
+ return request.bodyToMono(String.class).flatMap(body -> {
+ try {
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
+ return session.handle(message).flatMap(response -> ServerResponse.ok().build()).onErrorResume(error -> {
+ logger.error("Error processing message: {}", error.getMessage());
+ // TODO: instead of signalling the error, just respond with 200 OK
+ // - the error is signalled on the SSE connection
+ // return ServerResponse.ok().build();
+ 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"));
+ }
+ });
+ }
+
+ private class WebFluxMcpSessionTransport implements McpServerTransport {
+
+ private final FluxSink> sink;
+
+ public WebFluxMcpSessionTransport(FluxSink> sink) {
+ this.sink = sink;
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ return Mono.fromSupplier(() -> {
+ try {
+ return objectMapper.writeValueAsString(message);
+ }
+ catch (IOException e) {
+ throw Exceptions.propagate(e);
+ }
+ }).doOnNext(jsonText -> {
+ ServerSentEvent event = ServerSentEvent.builder()
+ .event(MESSAGE_EVENT_TYPE)
+ .data(jsonText)
+ .build();
+ sink.next(event);
+ }).doOnError(e -> {
+ // TODO log with sessionid
+ Throwable exception = Exceptions.unwrap(e);
+ sink.error(exception);
+ }).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeReference typeRef) {
+ return objectMapper.convertValue(data, typeRef);
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.fromRunnable(sink::complete);
+ }
+
+ @Override
+ public void close() {
+ sink.complete();
+ }
+
+ }
+
+}
diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java
new file mode 100644
index 000000000..2d9d055f3
--- /dev/null
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java
@@ -0,0 +1,503 @@
+/*
+ * Copyright 2024 - 2024 the original author or authors.
+ */
+package io.modelcontextprotocol;
+
+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.WebFluxSseServerTransportProvider;
+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.ModelPreferences;
+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.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import reactor.core.publisher.Mono;
+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.awaitility.Awaitility.await;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class WebFluxSseIntegrationTests {
+
+ private static final int PORT = 8182;
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private DisposableServer httpServer;
+
+ private WebFluxSseServerTransportProvider mcpServerTransportProvider;
+
+ ConcurrentHashMap clientBulders = new ConcurrentHashMap<>();
+
+ @BeforeEach
+ public void before() {
+
+ this.mcpServerTransportProvider = new WebFluxSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
+
+ HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransportProvider.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
+ // ---------------------------------------
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testCreateMessageWithoutSamplingCapabilities(String clientType) {
+
+ var clientBuilder = clientBulders.get(clientType);
+
+ McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
+
+ exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)).block();
+
+ return Mono.just(mock(CallToolResult.class));
+ });
+
+ McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
+
+ // Create client without sampling capabilities
+ var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
+
+ assertThat(client.initialize()).isNotNull();
+
+ try {
+ client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+ }
+ catch (McpError e) {
+ assertThat(e).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 {
+
+ // Client
+ var clientBuilder = clientBulders.get(clientType);
+
+ Function 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 mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ .capabilities(ClientCapabilities.builder().sampling().build())
+ .sampling(samplingHandler)
+ .build();
+
+ // Server
+
+ CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
+ null);
+
+ McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
+
+ var craeteMessageRequest = McpSchema.CreateMessageRequest.builder()
+ .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER,
+ new McpSchema.TextContent("Test message"))))
+ .modelPreferences(ModelPreferences.builder()
+ .hints(List.of())
+ .costPriority(1.0)
+ .speedPriority(1.0)
+ .intelligencePriority(1.0)
+ .build())
+ .build();
+
+ StepVerifier.create(exchange.createMessage(craeteMessageRequest)).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();
+
+ return Mono.just(callResponse);
+ });
+
+ var mcpServer = McpServer.async(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .tools(tool)
+ .build();
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+
+ assertThat(response).isNotNull();
+ assertThat(response).isEqualTo(callResponse);
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+ // ---------------------------------------
+ // Roots Tests
+ // ---------------------------------------
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testRootsSuccess(String clientType) {
+ var clientBuilder = clientBulders.get(clientType);
+
+ List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
+
+ AtomicReference> rootsRef = new AtomicReference<>();
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, 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();
+
+ 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);
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
+
+ exchange.listRoots(); // try to list roots
+
+ return mock(CallToolResult.class);
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> {
+ }).tools(tool).build();
+
+ // Create client without roots capability
+ // No roots capability
+ var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
+
+ assertThat(mcpClient.initialize()).isNotNull();
+
+ // Attempt to list roots should fail
+ try {
+ mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+ }
+ catch (McpError e) {
+ assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
+ }
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testRootsNotifciationWithEmptyRootsList(String clientType) {
+ var clientBuilder = clientBulders.get(clientType);
+
+ AtomicReference> rootsRef = new AtomicReference<>();
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, 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 testRootsWithMultipleHandlers(String clientType) {
+ var clientBuilder = clientBulders.get(clientType);
+
+ List roots = List.of(new Root("uri1://", "root1"));
+
+ AtomicReference> rootsRef1 = new AtomicReference<>();
+ AtomicReference> rootsRef2 = new AtomicReference<>();
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate))
+ .rootsChangeHandler((exchange, 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 roots = List.of(new Root("uri1://", "root1"));
+
+ AtomicReference> rootsRef = new AtomicReference<>();
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, 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 McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
+ McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, 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(mcpServerTransportProvider)
+ .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 McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
+ McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, 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(mcpServerTransportProvider)
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool1)
+ .build();
+
+ AtomicReference> 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.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), (exchange, request) -> callResponse);
+
+ mcpServer.addTool(tool2);
+
+ await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+ assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
+ });
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient", "webflux" })
+ void testInitialize(String clientType) {
+
+ var clientBuilder = clientBulders.get(clientType);
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider).build();
+
+ var mcpClient = clientBuilder.build();
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+}
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java
similarity index 61%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpAsyncClientTests.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java
index 761dd6ab6..2dd587d4f 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpAsyncClientTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java
@@ -1,27 +1,17 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client;
+package io.modelcontextprotocol.client;
+import java.time.Duration;
+
+import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
+import io.modelcontextprotocol.spec.McpClientTransport;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
-import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
import org.springframework.web.reactive.function.client.WebClient;
/**
@@ -42,7 +32,7 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
.waitingFor(Wait.forHttp("/").forStatusCode(404));
@Override
- protected ClientMcpTransport createMcpTransport() {
+ protected McpClientTransport createMcpTransport() {
return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host));
}
@@ -58,4 +48,8 @@ public void onClose() {
container.stop();
}
+ protected Duration getInitializationTimeout() {
+ return Duration.ofSeconds(1);
+ }
+
}
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java
similarity index 61%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpSyncClientTests.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java
index 41153afd2..72b390ddd 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpSyncClientTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java
@@ -1,27 +1,17 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client;
+package io.modelcontextprotocol.client;
+import java.time.Duration;
+
+import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
+import io.modelcontextprotocol.spec.McpClientTransport;
import org.junit.jupiter.api.Timeout;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
-import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
import org.springframework.web.reactive.function.client.WebClient;
/**
@@ -42,7 +32,7 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests {
.waitingFor(Wait.forHttp("/").forStatusCode(404));
@Override
- protected ClientMcpTransport createMcpTransport() {
+ protected McpClientTransport createMcpTransport() {
return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host));
}
@@ -58,4 +48,8 @@ protected void onClose() {
container.stop();
}
+ protected Duration getInitializationTimeout() {
+ return Duration.ofSeconds(1);
+ }
+
}
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransportTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java
similarity index 92%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransportTests.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java
index a75f851d7..912e04f14 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransportTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java
@@ -1,20 +1,8 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client.transport;
+package io.modelcontextprotocol.client.transport;
import java.time.Duration;
import java.util.Map;
@@ -22,6 +10,8 @@
import java.util.function.Function;
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -33,8 +23,6 @@
import reactor.core.publisher.Sinks;
import reactor.test.StepVerifier;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.reactive.function.client.WebClient;
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpAsyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java
similarity index 62%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpAsyncServerTests.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java
index b90c9a512..b460284ee 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpAsyncServerTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java
@@ -1,28 +1,16 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+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.ai.mcp.server.transport.WebFluxSseServerTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunctions;
@@ -32,8 +20,9 @@
*
* @author Christian Tzolov
*/
+@Deprecated
@Timeout(15) // Giving extra time beyond the client timeout
-class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
+class WebFluxSseMcpAsyncServerDeprecatedTests extends AbstractMcpAsyncServerDeprecatedTests {
private static final int PORT = 8181;
diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java
new file mode 100644
index 000000000..5fa787ab6
--- /dev/null
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+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 WebFluxSseServerTransportProvider}.
+ *
+ * @author Christian Tzolov
+ */
+@Timeout(15) // Giving extra time beyond the client timeout
+class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
+
+ private static final int PORT = 8181;
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private DisposableServer httpServer;
+
+ @Override
+ protected McpServerTransportProvider createMcpTransportProvider() {
+ var transportProvider = new WebFluxSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
+
+ HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction());
+ ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
+ httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
+ return transportProvider;
+ }
+
+ @Override
+ protected void onStart() {
+ }
+
+ @Override
+ protected void onClose() {
+ if (httpServer != null) {
+ httpServer.disposeNow();
+ }
+ }
+
+}
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpSyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java
similarity index 62%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpSyncServerTests.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java
index 048d6398b..be2bf6c7f 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpSyncServerTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java
@@ -1,28 +1,16 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+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.ai.mcp.server.transport.WebFluxSseServerTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunctions;
@@ -32,8 +20,9 @@
*
* @author Christian Tzolov
*/
+@Deprecated
@Timeout(15) // Giving extra time beyond the client timeout
-class WebFluxSseMcpSyncServerTests extends AbstractMcpSyncServerTests {
+class WebFluxSseMcpSyncServerDeprecatecTests extends AbstractMcpSyncServerDeprecatedTests {
private static final int PORT = 8182;
diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java
new file mode 100644
index 000000000..d3672e3f3
--- /dev/null
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+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 WebFluxSseServerTransportProvider}.
+ *
+ * @author Christian Tzolov
+ */
+@Timeout(15) // Giving extra time beyond the client timeout
+class WebFluxSseMcpSyncServerTests extends AbstractMcpSyncServerTests {
+
+ private static final int PORT = 8182;
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private DisposableServer httpServer;
+
+ private WebFluxSseServerTransportProvider transportProvider;
+
+ @Override
+ protected McpServerTransportProvider createMcpTransportProvider() {
+ transportProvider = new WebFluxSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
+ return transportProvider;
+ }
+
+ @Override
+ protected void onStart() {
+ HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction());
+ ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
+ httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
+ }
+
+ @Override
+ protected void onClose() {
+ if (httpServer != null) {
+ httpServer.disposeNow();
+ }
+ }
+
+}
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java
similarity index 80%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java
index ca968cfb7..981e114c9 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java
@@ -1,19 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp;
+package io.modelcontextprotocol.server.legacy;
import java.time.Duration;
import java.util.List;
@@ -23,6 +11,23 @@
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;
@@ -32,23 +37,6 @@
import reactor.netty.http.server.HttpServer;
import reactor.test.StepVerifier;
-import org.springframework.ai.mcp.client.McpClient;
-import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport;
-import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport;
-import org.springframework.ai.mcp.server.McpServer;
-import org.springframework.ai.mcp.server.McpServerFeatures;
-import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport;
-import org.springframework.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
-import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult;
-import org.springframework.ai.mcp.spec.McpSchema.InitializeResult;
-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;
@@ -100,12 +88,11 @@ public void after() {
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 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 McpSchema.CreateMessageRequest(messages, modelPrefs, null,
- McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
+ 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)
@@ -126,12 +113,11 @@ void testCreateMessageWithoutSamplingCapabilities(String clientType) {
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
- var messages = List
- .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
+ 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 McpSchema.CreateMessageRequest(messages, modelPrefs, null,
- McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
+ 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)
@@ -163,12 +149,11 @@ void testCreateMessageSuccess(String clientType) throws InterruptedException {
InitializeResult initResult = client.initialize();
assertThat(initResult).isNotNull();
- var messages = List
- .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message")));
+ 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 McpSchema.CreateMessageRequest(messages, modelPrefs, null,
- McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of());
+ 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();
@@ -367,13 +352,13 @@ void testToolCallSuccess(String clientType) {
var clientBuilder = clientBulders.get(clientType);
- var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
+ var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
- new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
+ new 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")
+ .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
@@ -407,13 +392,13 @@ void testToolListChangeHandlingSuccess(String clientType) {
var clientBuilder = clientBulders.get(clientType);
- var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
+ var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration(
- new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> {
+ new 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")
+ .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
@@ -430,7 +415,7 @@ void testToolListChangeHandlingSuccess(String clientType) {
// 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")
+ .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
@@ -459,7 +444,7 @@ void testToolListChangeHandlingSuccess(String clientType) {
// Add a new tool
McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration(
- new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
+ new Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse);
mcpServer.addTool(tool2);
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java
similarity index 68%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java
rename to mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java
index 96690dbd8..0ab72a99f 100644
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java
+++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java
@@ -1,19 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-* https://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
*/
-package org.springframework.ai.mcp.server.transport;
+package io.modelcontextprotocol.server.transport;
import java.io.IOException;
import java.io.InputStream;
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/resources/logback.xml b/mcp-spring/mcp-spring-webflux/src/test/resources/logback.xml
similarity index 100%
rename from mcp-transport/mcp-webflux-sse-transport/src/test/resources/logback.xml
rename to mcp-spring/mcp-spring-webflux/src/test/resources/logback.xml
diff --git a/mcp-transport/mcp-webmvc-sse-transport/README.md b/mcp-spring/mcp-spring-webmvc/README.md
similarity index 82%
rename from mcp-transport/mcp-webmvc-sse-transport/README.md
rename to mcp-spring/mcp-spring-webmvc/README.md
index 0b73e73c2..9adf5b2ee 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/README.md
+++ b/mcp-spring/mcp-spring-webmvc/README.md
@@ -2,8 +2,8 @@
```xml
- org.springframework.experimental
- mcp-webmvc-sse-transport
+ io.modelcontextprotocol.sdk
+ mcp-spring-webmvc
```
diff --git a/mcp-transport/mcp-webmvc-sse-transport/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml
similarity index 83%
rename from mcp-transport/mcp-webmvc-sse-transport/pom.xml
rename to mcp-spring/mcp-spring-webmvc/pom.xml
index 95c7df1ba..dc198ac14 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/pom.xml
+++ b/mcp-spring/mcp-spring-webmvc/pom.xml
@@ -4,34 +4,34 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp-parent
- 0.6.0
+ 0.8.0
../../pom.xml
- mcp-webmvc-sse-transport
+ mcp-spring-webmvc
jar
Spring Web MVC implementation of the Java MCP SSE transport
- https://github.com/spring-projects-experimental/spring-ai-mcp
+ https://github.com/modelcontextprotocol/java-sdk
- https://github.com/spring-projects-experimental/spring-ai-mcp
- git://github.com/spring-projects-experimental/spring-ai-mcp.git
- git@github.com:spring-projects-experimental/spring-ai-mcp.git
+ https://github.com/modelcontextprotocol/java-sdk
+ git://github.com/modelcontextprotocol/java-sdk.git
+ git@github.com/modelcontextprotocol/java-sdk.git
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp
- 0.6.0
+ 0.8.0
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp-test
- 0.6.0
+ 0.8.0
test
diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebMvcSseServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java
similarity index 93%
rename from mcp-transport/mcp-webmvc-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebMvcSseServerTransport.java
rename to mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java
index cee0b48ce..23193d106 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebMvcSseServerTransport.java
+++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java
@@ -1,36 +1,25 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server.transport;
+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.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
-import org.springframework.ai.mcp.util.Assert;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
@@ -44,6 +33,9 @@
* 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}.
+ *
*
* Key features:
*
@@ -68,12 +60,12 @@
* 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);
@@ -229,6 +221,14 @@ private ServerResponse handleSseConnection(ServerRequest request) {
// 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);
@@ -240,7 +240,7 @@ private ServerResponse handleSseConnection(ServerRequest request) {
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());
@@ -271,6 +271,7 @@ private ServerResponse handleMessage(ServerRequest request) {
// 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();
@@ -364,7 +365,7 @@ public Mono closeGracefully() {
sessions.remove(sessionId);
});
- logger.info("Graceful shutdown completed");
+ logger.debug("Graceful shutdown completed");
});
}
diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java
new file mode 100644
index 000000000..65416b256
--- /dev/null
+++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java
@@ -0,0 +1,399 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+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.McpServerTransport;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import io.modelcontextprotocol.spec.McpServerSession;
+import io.modelcontextprotocol.util.Assert;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+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.
+ *
+ *
+ * Key features:
+ *
+ * Implements bidirectional communication using HTTP POST for client-to-server
+ * messages and SSE for server-to-client messages
+ * Manages client sessions with unique IDs for reliable message delivery
+ * Supports graceful shutdown with proper session cleanup
+ * Provides JSON-RPC message handling through configured endpoints
+ * Includes built-in error handling and logging
+ *
+ *
+ *
+ * The transport operates on two main endpoints:
+ *
+ * {@code /sse} - The SSE endpoint where clients establish their event stream
+ * connection
+ * A configurable message endpoint where clients send their JSON-RPC messages via HTTP
+ * POST
+ *
+ *
+ *
+ * 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 McpServerTransportProvider
+ * @see RouterFunction
+ */
+public class WebMvcSseServerTransportProvider implements McpServerTransportProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(WebMvcSseServerTransportProvider.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;
+
+ private McpServerSession.Factory sessionFactory;
+
+ /**
+ * Map of active client sessions, keyed by session ID.
+ */
+ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+
+ /**
+ * Flag indicating if the transport is shutting down.
+ */
+ private volatile boolean isClosing = false;
+
+ /**
+ * Constructs a new WebMvcSseServerTransportProvider 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.
+ * @param sseEndpoint The endpoint URI where clients establish their SSE connections.
+ * @throws IllegalArgumentException if any parameter is null
+ */
+ public WebMvcSseServerTransportProvider(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 WebMvcSseServerTransportProvider 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 WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint) {
+ this(objectMapper, messageEndpoint, DEFAULT_SSE_ENDPOINT);
+ }
+
+ @Override
+ public void setSessionFactory(McpServerSession.Factory sessionFactory) {
+ this.sessionFactory = sessionFactory;
+ }
+
+ /**
+ * Broadcasts a notification 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 method The method name for the notification
+ * @param params The parameters for the notification
+ * @return A Mono that completes when the broadcast attempt is finished
+ */
+ @Override
+ public Mono notifyClients(String method, Map params) {
+ if (sessions.isEmpty()) {
+ logger.debug("No active sessions to broadcast message to");
+ return Mono.empty();
+ }
+
+ logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
+
+ return Flux.fromIterable(sessions.values())
+ .flatMap(session -> session.sendNotification(method, params)
+ .doOnError(
+ e -> logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()))
+ .onErrorComplete())
+ .then();
+ }
+
+ /**
+ * Initiates a graceful shutdown of the transport. This method:
+ *
+ * Sets the closing flag to prevent new connections
+ * Closes all active SSE connections
+ * Removes all session records
+ *
+ * @return A Mono that completes when all cleanup operations are finished
+ */
+ @Override
+ public Mono closeGracefully() {
+ return Flux.fromIterable(sessions.values()).doFirst(() -> {
+ this.isClosing = true;
+ logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size());
+ })
+ .flatMap(McpServerSession::closeGracefully)
+ .then()
+ .doOnSuccess(v -> logger.debug("Graceful shutdown completed"));
+ }
+
+ /**
+ * Returns the RouterFunction that defines the HTTP endpoints for this transport. The
+ * router function handles two endpoints:
+ *
+ * GET /sse - For establishing SSE connections
+ * POST [messageEndpoint] - For receiving JSON-RPC messages from clients
+ *
+ * @return The configured RouterFunction for handling HTTP requests
+ */
+ public RouterFunction getRouterFunction() {
+ return this.routerFunction;
+ }
+
+ /**
+ * Handles new SSE connection requests from clients by creating a new session and
+ * establishing an SSE connection. This method:
+ *
+ * Generates a unique session ID
+ * Creates a new session with a WebMvcMcpSessionTransport
+ * Sends an initial endpoint event to inform the client where to send
+ * messages
+ * Maintains the session in the sessions map
+ *
+ * @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);
+ });
+
+ WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sessionId, sseBuilder);
+ McpServerSession session = sessionFactory.create(sessionTransport);
+ this.sessions.put(sessionId, session);
+
+ try {
+ sseBuilder.id(sessionId)
+ .event(ENDPOINT_EVENT_TYPE)
+ .data(messageEndpoint + "?sessionId=" + sessionId);
+ }
+ catch (Exception e) {
+ logger.error("Failed to send initial endpoint event: {}", 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:
+ *
+ * Deserializes the request body into a JSON-RPC message
+ * Processes the message through the session's handle method
+ * Returns appropriate HTTP responses based on the processing result
+ *
+ * @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");
+ }
+
+ if (!request.param("sessionId").isPresent()) {
+ return ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint"));
+ }
+
+ String sessionId = request.param("sessionId").get();
+ McpServerSession session = sessions.get(sessionId);
+
+ if (session == null) {
+ return ServerResponse.status(HttpStatus.NOT_FOUND).body(new McpError("Session not found: " + sessionId));
+ }
+
+ try {
+ String body = request.body(String.class);
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
+
+ // Process the message through the session's handle method
+ session.handle(message).block(); // Block for WebMVC compatibility
+
+ 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()));
+ }
+ }
+
+ /**
+ * Implementation of McpServerTransport for WebMVC SSE sessions. This class handles
+ * the transport-level communication for a specific client session.
+ */
+ private class WebMvcMcpSessionTransport implements McpServerTransport {
+
+ private final String sessionId;
+
+ private final SseBuilder sseBuilder;
+
+ /**
+ * Creates a new session transport with the specified ID and SSE builder.
+ * @param sessionId The unique identifier for this session
+ * @param sseBuilder The SSE builder for sending server events to the client
+ */
+ WebMvcMcpSessionTransport(String sessionId, SseBuilder sseBuilder) {
+ this.sessionId = sessionId;
+ this.sseBuilder = sseBuilder;
+ logger.debug("Session transport {} initialized with SSE builder", sessionId);
+ }
+
+ /**
+ * Sends a JSON-RPC message to the client through the SSE connection.
+ * @param message The JSON-RPC message to send
+ * @return A Mono that completes when the message has been sent
+ */
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ return Mono.fromRunnable(() -> {
+ try {
+ String jsonText = objectMapper.writeValueAsString(message);
+ sseBuilder.id(sessionId).event(MESSAGE_EVENT_TYPE).data(jsonText);
+ logger.debug("Message sent to session {}", sessionId);
+ }
+ catch (Exception e) {
+ logger.error("Failed to send message to session {}: {}", sessionId, e.getMessage());
+ sseBuilder.error(e);
+ }
+ });
+ }
+
+ /**
+ * Converts data from one type to another using the configured ObjectMapper.
+ * @param data The source data object to convert
+ * @param typeRef The target type reference
+ * @return The converted object of type T
+ * @param The target type
+ */
+ @Override
+ public T unmarshalFrom(Object data, TypeReference typeRef) {
+ return objectMapper.convertValue(data, typeRef);
+ }
+
+ /**
+ * Initiates a graceful shutdown of the transport.
+ * @return A Mono that completes when the shutdown is complete
+ */
+ @Override
+ public Mono closeGracefully() {
+ return Mono.fromRunnable(() -> {
+ logger.debug("Closing session transport: {}", sessionId);
+ try {
+ sseBuilder.complete();
+ logger.debug("Successfully completed SSE builder for session {}", sessionId);
+ }
+ catch (Exception e) {
+ logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * Closes the transport immediately.
+ */
+ @Override
+ public void close() {
+ try {
+ sseBuilder.complete();
+ logger.debug("Successfully completed SSE builder for session {}", sessionId);
+ }
+ catch (Exception e) {
+ logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage());
+ }
+ }
+
+ }
+
+}
diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseSyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportDeprecatedTests.java
similarity index 79%
rename from mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseSyncServerTransportTests.java
rename to mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportDeprecatedTests.java
index 2c44c570f..c3f0e3220 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseSyncServerTransportTests.java
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportDeprecatedTests.java
@@ -1,29 +1,17 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+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.ai.mcp.server.transport.WebMvcSseServerTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
@@ -32,8 +20,9 @@
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
+@Deprecated
@Timeout(15)
-class WebMvcSseSyncServerTransportTests extends AbstractMcpSyncServerTests {
+class WebMvcSseAsyncServerTransportDeprecatedTests extends AbstractMcpAsyncServerDeprecatedTests {
private static final String MESSAGE_ENDPOINT = "/mcp/message";
diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportTests.java
new file mode 100644
index 000000000..08d5de671
--- /dev/null
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportTests.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+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;
+
+@Timeout(15)
+class WebMvcSseAsyncServerTransportTests extends AbstractMcpAsyncServerTests {
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private static final int PORT = 8181;
+
+ private Tomcat tomcat;
+
+ private McpServerTransportProvider transportProvider;
+
+ @Configuration
+ @EnableWebMvc
+ static class TestConfig {
+
+ @Bean
+ public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
+ return new WebMvcSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
+ }
+
+ @Bean
+ public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) {
+ return transportProvider.getRouterFunction();
+ }
+
+ }
+
+ private AnnotationConfigWebApplicationContext appContext;
+
+ @Override
+ protected McpServerTransportProvider createMcpTransportProvider() {
+ // Set up Tomcat first
+ tomcat = new Tomcat();
+ tomcat.setPort(PORT);
+
+ // Set Tomcat base directory to java.io.tmpdir to avoid permission issues
+ String baseDir = System.getProperty("java.io.tmpdir");
+ tomcat.setBaseDir(baseDir);
+
+ // Use the same directory for document base
+ Context context = tomcat.addContext("", baseDir);
+
+ // Create and configure Spring WebMvc context
+ appContext = new AnnotationConfigWebApplicationContext();
+ appContext.register(TestConfig.class);
+ appContext.setServletContext(context.getServletContext());
+ appContext.refresh();
+
+ // Get the transport from Spring context
+ transportProvider = appContext.getBean(WebMvcSseServerTransportProvider.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 transportProvider;
+ }
+
+ @Override
+ protected void onStart() {
+ }
+
+ @Override
+ protected void onClose() {
+ if (transportProvider != null) {
+ transportProvider.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);
+ }
+ }
+ }
+
+}
diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationDeprecatedTests.java
similarity index 88%
rename from mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java
rename to mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationDeprecatedTests.java
index 308be78f4..f2b593d8d 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationDeprecatedTests.java
@@ -1,19 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+package io.modelcontextprotocol.server;
import java.time.Duration;
import java.util.List;
@@ -22,6 +10,20 @@
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;
@@ -29,24 +31,8 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-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.HttpClientSseClientTransport;
-import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport;
-import org.springframework.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
-import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult;
-import org.springframework.ai.mcp.spec.McpSchema.InitializeResult;
-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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@@ -60,9 +46,8 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
-public class WebMvcSseIntegrationTests {
-
- private static final Logger logger = LoggerFactory.getLogger(WebMvcSseIntegrationTests.class);
+@Deprecated
+public class WebMvcSseIntegrationDeprecatedTests {
private static final int PORT = 8183;
@@ -411,7 +396,7 @@ void testToolCallSuccess() {
// 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")
+ .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
@@ -448,7 +433,7 @@ void testToolListChangeHandlingSuccess() {
// 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")
+ .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
@@ -465,7 +450,7 @@ void testToolListChangeHandlingSuccess() {
// 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")
+ .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md")
.retrieve()
.body(String.class);
assertThat(response).isNotBlank();
diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java
new file mode 100644
index 000000000..3ff755ca9
--- /dev/null
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java
@@ -0,0 +1,535 @@
+/*
+ * 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.WebMvcSseServerTransportProvider;
+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.ModelPreferences;
+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.core.publisher.Mono;
+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.awaitility.Awaitility.await;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.mock;
+
+public class WebMvcSseIntegrationTests {
+
+ private static final int PORT = 8183;
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private WebMvcSseServerTransportProvider mcpServerTransportProvider;
+
+ McpClient.SyncSpec clientBuilder;
+
+ @Configuration
+ @EnableWebMvc
+ static class TestConfig {
+
+ @Bean
+ public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
+ return new WebMvcSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
+ }
+
+ @Bean
+ public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) {
+ return transportProvider.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
+ mcpServerTransportProvider = appContext.getBean(WebMvcSseServerTransportProvider.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 (mcpServerTransportProvider != null) {
+ mcpServerTransportProvider.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 testCreateMessageWithoutSamplingCapabilities() {
+
+ McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
+
+ exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)).block();
+
+ return Mono.just(mock(CallToolResult.class));
+ });
+
+ McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
+
+ // Create client without sampling capabilities
+ var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
+
+ assertThat(client.initialize()).isNotNull();
+
+ try {
+ client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+ }
+ catch (McpError e) {
+ assertThat(e).isInstanceOf(McpError.class)
+ .hasMessage("Client must be configured with sampling capabilities");
+ }
+ }
+
+ @Test
+ void testCreateMessageSuccess() throws InterruptedException {
+
+ // Client
+
+ Function 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 mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ .capabilities(ClientCapabilities.builder().sampling().build())
+ .sampling(samplingHandler)
+ .build();
+
+ // Server
+
+ CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
+ null);
+
+ McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
+
+ var craeteMessageRequest = McpSchema.CreateMessageRequest.builder()
+ .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER,
+ new McpSchema.TextContent("Test message"))))
+ .modelPreferences(ModelPreferences.builder()
+ .hints(List.of())
+ .costPriority(1.0)
+ .speedPriority(1.0)
+ .intelligencePriority(1.0)
+ .build())
+ .build();
+
+ StepVerifier.create(exchange.createMessage(craeteMessageRequest)).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();
+
+ return Mono.just(callResponse);
+ });
+
+ var mcpServer = McpServer.async(mcpServerTransportProvider)
+ .serverInfo("test-server", "1.0.0")
+ .tools(tool)
+ .build();
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+
+ assertThat(response).isNotNull();
+ assertThat(response).isEqualTo(callResponse);
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+ // ---------------------------------------
+ // Roots Tests
+ // ---------------------------------------
+ @Test
+ void testRootsSuccess() {
+ List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
+
+ AtomicReference> rootsRef = new AtomicReference<>();
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, 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();
+
+ 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() {
+
+ McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
+
+ exchange.listRoots(); // try to list roots
+
+ return mock(CallToolResult.class);
+ });
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> {
+ }).tools(tool).build();
+
+ // Create client without roots capability
+ // No roots capability
+ var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
+
+ assertThat(mcpClient.initialize()).isNotNull();
+
+ // Attempt to list roots should fail
+ try {
+ mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+ }
+ catch (McpError e) {
+ assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
+ }
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+ @Test
+ void testRootsNotifciationWithEmptyRootsList() {
+ AtomicReference> rootsRef = new AtomicReference<>();
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, 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 testRootsWithMultipleHandlers() {
+ List roots = List.of(new Root("uri1://", "root1"));
+
+ AtomicReference> rootsRef1 = new AtomicReference<>();
+ AtomicReference> rootsRef2 = new AtomicReference<>();
+
+ var mcpServer = McpServer.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate))
+ .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
+ .build();
+
+ var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
+ .roots(roots)
+ .build();
+
+ assertThat(mcpClient.initialize()).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.sync(mcpServerTransportProvider)
+ .rootsChangeHandler((exchange, 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.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, 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(mcpServerTransportProvider)
+ .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.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, 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(mcpServerTransportProvider)
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool1)
+ .build();
+
+ AtomicReference> 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.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
+ new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), (exchange, 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(mcpServerTransportProvider).build();
+
+ var mcpClient = clientBuilder.build();
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ mcpClient.close();
+ mcpServer.close();
+ }
+
+}
diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseAsyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportDeprecatedTests.java
similarity index 79%
rename from mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseAsyncServerTransportTests.java
rename to mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportDeprecatedTests.java
index 374a5c59d..8656665ed 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseAsyncServerTransportTests.java
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportDeprecatedTests.java
@@ -1,29 +1,17 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+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.ai.mcp.server.transport.WebMvcSseServerTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
@@ -32,8 +20,9 @@
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
+@Deprecated
@Timeout(15)
-class WebMvcSseAsyncServerTransportTests extends AbstractMcpAsyncServerTests {
+class WebMvcSseSyncServerTransportDeprecatedTests extends AbstractMcpSyncServerDeprecatedTests {
private static final String MESSAGE_ENDPOINT = "/mcp/message";
diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportTests.java
new file mode 100644
index 000000000..b85bed379
--- /dev/null
+++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportTests.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
+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;
+
+@Timeout(15)
+class WebMvcSseSyncServerTransportTests extends AbstractMcpSyncServerTests {
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private static final int PORT = 8181;
+
+ private Tomcat tomcat;
+
+ private WebMvcSseServerTransportProvider transportProvider;
+
+ @Configuration
+ @EnableWebMvc
+ static class TestConfig {
+
+ @Bean
+ public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
+ return new WebMvcSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
+ }
+
+ @Bean
+ public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) {
+ return transportProvider.getRouterFunction();
+ }
+
+ }
+
+ private AnnotationConfigWebApplicationContext appContext;
+
+ @Override
+ protected WebMvcSseServerTransportProvider createMcpTransportProvider() {
+ // Set up Tomcat first
+ tomcat = new Tomcat();
+ tomcat.setPort(PORT);
+
+ // Set Tomcat base directory to java.io.tmpdir to avoid permission issues
+ String baseDir = System.getProperty("java.io.tmpdir");
+ tomcat.setBaseDir(baseDir);
+
+ // Use the same directory for document base
+ Context context = tomcat.addContext("", baseDir);
+
+ // Create and configure Spring WebMvc context
+ appContext = new AnnotationConfigWebApplicationContext();
+ appContext.register(TestConfig.class);
+ appContext.setServletContext(context.getServletContext());
+ appContext.refresh();
+
+ // Get the transport from Spring context
+ transportProvider = appContext.getBean(WebMvcSseServerTransportProvider.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 transportProvider;
+ }
+
+ @Override
+ protected void onStart() {
+ }
+
+ @Override
+ protected void onClose() {
+ if (transportProvider != null) {
+ transportProvider.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);
+ }
+ }
+ }
+
+}
diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/resources/logback.xml b/mcp-spring/mcp-spring-webmvc/src/test/resources/logback.xml
similarity index 66%
rename from mcp-transport/mcp-webmvc-sse-transport/src/test/resources/logback.xml
rename to mcp-spring/mcp-spring-webmvc/src/test/resources/logback.xml
index 517af52f3..bc1140bb5 100644
--- a/mcp-transport/mcp-webmvc-sse-transport/src/test/resources/logback.xml
+++ b/mcp-spring/mcp-spring-webmvc/src/test/resources/logback.xml
@@ -9,16 +9,16 @@
-
+
-
+
-
+
-
+
diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml
index 212003a36..033043985 100644
--- a/mcp-test/pom.xml
+++ b/mcp-test/pom.xml
@@ -4,27 +4,27 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp-parent
- 0.6.0
+ 0.8.0
mcp-test
jar
Tests for the Java MCP SDK
Provides some shared test fasilities for the MCP Java SDK
- https://github.com/spring-projects-experimental/spring-ai-mcp
+ https://github.com/modelcontextprotocol/java-sdk
- https://github.com/spring-projects-experimental/spring-ai-mcp
- git://github.com/spring-projects-experimental/spring-ai-mcp.git
- git@github.com:spring-projects-experimental/spring-ai-mcp.git
+ https://github.com/modelcontextprotocol/java-sdk
+ git://github.com/modelcontextprotocol/java-sdk.git
+ git@github.com/modelcontextprotocol/java-sdk.git
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp
- 0.6.0
+ 0.8.0
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java b/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java
new file mode 100644
index 000000000..cef3fb9fa
--- /dev/null
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
+import io.modelcontextprotocol.spec.ServerMcpTransport;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+/**
+ * A mock implementation of the {@link McpClientTransport} and {@link ServerMcpTransport}
+ * interfaces.
+ */
+public class MockMcpTransport implements McpClientTransport, ServerMcpTransport {
+
+ private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer();
+
+ private final List sent = new ArrayList<>();
+
+ private final BiConsumer interceptor;
+
+ public MockMcpTransport() {
+ this((t, msg) -> {
+ });
+ }
+
+ public MockMcpTransport(BiConsumer interceptor) {
+ this.interceptor = interceptor;
+ }
+
+ public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
+ if (inbound.tryEmitNext(message).isFailure()) {
+ throw new RuntimeException("Failed to process incoming message " + message);
+ }
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ sent.add(message);
+ interceptor.accept(this, message);
+ return Mono.empty();
+ }
+
+ public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
+ return (JSONRPCRequest) getLastSentMessage();
+ }
+
+ public McpSchema.JSONRPCNotification getLastSentMessageAsNotification() {
+ return (JSONRPCNotification) getLastSentMessage();
+ }
+
+ public McpSchema.JSONRPCMessage getLastSentMessage() {
+ return !sent.isEmpty() ? sent.get(sent.size() - 1) : null;
+ }
+
+ private volatile boolean connected = false;
+
+ @Override
+ public Mono connect(Function, Mono> handler) {
+ if (connected) {
+ return Mono.error(new IllegalStateException("Already connected"));
+ }
+ connected = true;
+ return inbound.asFlux()
+ .flatMap(message -> Mono.just(message).transform(handler))
+ .doFinally(signal -> connected = false)
+ .then();
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.defer(() -> {
+ connected = false;
+ inbound.tryEmitComplete();
+ // Wait for all subscribers to complete
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeReference typeRef) {
+ return new ObjectMapper().convertValue(data, typeRef);
+ }
+
+}
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java
new file mode 100644
index 000000000..713563519
--- /dev/null
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java
@@ -0,0 +1,491 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
+import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
+import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.Root;
+import io.modelcontextprotocol.spec.McpSchema.SubscribeRequest;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest;
+import io.modelcontextprotocol.spec.McpTransport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Test suite for the {@link McpAsyncClient} that can be used with different
+ * {@link McpTransport} implementations.
+ *
+ * @author Christian Tzolov
+ * @author Dariusz Jędrzejczyk
+ */
+public abstract class AbstractMcpAsyncClientTests {
+
+ private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!";
+
+ abstract protected McpClientTransport createMcpTransport();
+
+ protected void onStart() {
+ }
+
+ protected void onClose() {
+ }
+
+ protected Duration getRequestTimeout() {
+ return Duration.ofSeconds(14);
+ }
+
+ protected Duration getInitializationTimeout() {
+ return Duration.ofSeconds(2);
+ }
+
+ McpAsyncClient client(McpClientTransport transport) {
+ return client(transport, Function.identity());
+ }
+
+ McpAsyncClient client(McpClientTransport transport, Function customizer) {
+ AtomicReference client = new AtomicReference<>();
+
+ assertThatCode(() -> {
+ McpClient.AsyncSpec builder = McpClient.async(transport)
+ .requestTimeout(getRequestTimeout())
+ .initializationTimeout(getInitializationTimeout())
+ .capabilities(ClientCapabilities.builder().roots(true).build());
+ builder = customizer.apply(builder);
+ client.set(builder.build());
+ }).doesNotThrowAnyException();
+
+ return client.get();
+ }
+
+ void withClient(McpClientTransport transport, Consumer c) {
+ withClient(transport, Function.identity(), c);
+ }
+
+ void withClient(McpClientTransport transport, Function customizer,
+ Consumer c) {
+ var client = client(transport, customizer);
+ try {
+ c.accept(client);
+ }
+ finally {
+ StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10));
+ }
+ }
+
+ @BeforeEach
+ void setUp() {
+ onStart();
+ }
+
+ @AfterEach
+ void tearDown() {
+ onClose();
+ }
+
+ void verifyInitializationTimeout(Function> operation, String action) {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.withVirtualTime(() -> operation.apply(mcpAsyncClient))
+ .expectSubscription()
+ .thenAwait(getInitializationTimeout())
+ .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
+ .hasMessage("Client must be initialized before " + action))
+ .verify();
+ });
+ }
+
+ @Test
+ void testConstructorWithInvalidArguments() {
+ assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Transport must not be null");
+
+ assertThatThrownBy(() -> McpClient.async(createMcpTransport()).requestTimeout(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Request timeout must not be null");
+ }
+
+ @Test
+ void testListToolsWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.listTools(null), "listing tools");
+ }
+
+ @Test
+ void testListTools() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null)))
+ .consumeNextWith(result -> {
+ assertThat(result.tools()).isNotNull().isNotEmpty();
+
+ Tool firstTool = result.tools().get(0);
+ assertThat(firstTool.name()).isNotNull();
+ assertThat(firstTool.description()).isNotNull();
+ })
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testPingWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.ping(), "pinging the server");
+ }
+
+ @Test
+ void testPing() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping()))
+ .expectNextCount(1)
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testCallToolWithoutInitialization() {
+ CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
+ verifyInitializationTimeout(client -> client.callTool(callToolRequest), "calling tools");
+ }
+
+ @Test
+ void testCallTool() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
+
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest)))
+ .consumeNextWith(callToolResult -> {
+ assertThat(callToolResult).isNotNull().satisfies(result -> {
+ assertThat(result.content()).isNotNull();
+ assertThat(result.isError()).isNull();
+ });
+ })
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testCallToolWithInvalidTool() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool",
+ Map.of("message", ECHO_TEST_MESSAGE));
+
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest)))
+ .consumeErrorWith(
+ e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Unknown tool: nonexistent_tool"))
+ .verify();
+ });
+ }
+
+ @Test
+ void testListResourcesWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.listResources(null), "listing resources");
+ }
+
+ @Test
+ void testListResources() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null)))
+ .consumeNextWith(resources -> {
+ assertThat(resources).isNotNull().satisfies(result -> {
+ assertThat(result.resources()).isNotNull();
+
+ if (!result.resources().isEmpty()) {
+ Resource firstResource = result.resources().get(0);
+ assertThat(firstResource.uri()).isNotNull();
+ assertThat(firstResource.name()).isNotNull();
+ }
+ });
+ })
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testMcpAsyncClientState() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ assertThat(mcpAsyncClient).isNotNull();
+ });
+ }
+
+ @Test
+ void testListPromptsWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.listPrompts(null), "listing " + "prompts");
+ }
+
+ @Test
+ void testListPrompts() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null)))
+ .consumeNextWith(prompts -> {
+ assertThat(prompts).isNotNull().satisfies(result -> {
+ assertThat(result.prompts()).isNotNull();
+
+ if (!result.prompts().isEmpty()) {
+ Prompt firstPrompt = result.prompts().get(0);
+ assertThat(firstPrompt.name()).isNotNull();
+ assertThat(firstPrompt.description()).isNotNull();
+ }
+ });
+ })
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testGetPromptWithoutInitialization() {
+ GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of());
+ verifyInitializationTimeout(client -> client.getPrompt(request), "getting " + "prompts");
+ }
+
+ @Test
+ void testGetPrompt() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier
+ .create(mcpAsyncClient.initialize()
+ .then(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of()))))
+ .consumeNextWith(prompt -> {
+ assertThat(prompt).isNotNull().satisfies(result -> {
+ assertThat(result.messages()).isNotEmpty();
+ assertThat(result.messages()).hasSize(1);
+ });
+ })
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testRootsListChangedWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.rootsListChangedNotification(),
+ "sending roots list changed notification");
+ }
+
+ @Test
+ void testRootsListChanged() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification()))
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testInitializeWithRootsListProviders() {
+ withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")),
+ client -> {
+ StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete();
+ });
+ }
+
+ @Test
+ void testAddRoot() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ Root newRoot = new Root("file:///new/test/path", "new-test-root");
+ StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete();
+ });
+ }
+
+ @Test
+ void testAddRootWithNullValue() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.addRoot(null))
+ .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null"))
+ .verify();
+ });
+ }
+
+ @Test
+ void testRemoveRoot() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ Root root = new Root("file:///test/path/to/remove", "root-to-remove");
+ StepVerifier.create(mcpAsyncClient.addRoot(root)).verifyComplete();
+
+ StepVerifier.create(mcpAsyncClient.removeRoot(root.uri())).verifyComplete();
+ });
+ }
+
+ @Test
+ void testRemoveNonExistentRoot() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri"))
+ .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
+ .hasMessage("Root with uri 'nonexistent-uri' not found"))
+ .verify();
+ });
+ }
+
+ @Test
+ @Disabled
+ void testReadResource() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
+ if (!resources.resources().isEmpty()) {
+ Resource firstResource = resources.resources().get(0);
+ StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> {
+ assertThat(result).isNotNull();
+ assertThat(result.contents()).isNotNull();
+ }).verifyComplete();
+ }
+ }).verifyComplete();
+ });
+ }
+
+ @Test
+ void testListResourceTemplatesWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.listResourceTemplates(), "listing resource templates");
+ }
+
+ @Test
+ void testListResourceTemplates() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates()))
+ .consumeNextWith(result -> {
+ assertThat(result).isNotNull();
+ assertThat(result.resourceTemplates()).isNotNull();
+ })
+ .verifyComplete();
+ });
+ }
+
+ // @Test
+ void testResourceSubscription() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
+ if (!resources.resources().isEmpty()) {
+ Resource firstResource = resources.resources().get(0);
+
+ // Test subscribe
+ StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())))
+ .verifyComplete();
+
+ // Test unsubscribe
+ StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri())))
+ .verifyComplete();
+ }
+ }).verifyComplete();
+ });
+ }
+
+ @Test
+ void testNotificationHandlers() {
+ AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false);
+ AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false);
+ AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
+
+ withClient(createMcpTransport(),
+ builder -> builder
+ .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
+ .resourcesChangeConsumer(
+ resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
+ .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))),
+ mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.initialize())
+ .expectNextMatches(Objects::nonNull)
+ .verifyComplete();
+ });
+ }
+
+ @Test
+ void testInitializeWithSamplingCapability() {
+ ClientCapabilities capabilities = ClientCapabilities.builder().sampling().build();
+ CreateMessageResult createMessageResult = CreateMessageResult.builder()
+ .message("test")
+ .model("test-model")
+ .build();
+ withClient(createMcpTransport(),
+ builder -> builder.capabilities(capabilities).sampling(request -> Mono.just(createMessageResult)),
+ client -> {
+ StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete();
+ });
+ }
+
+ @Test
+ void testInitializeWithAllCapabilities() {
+ var capabilities = ClientCapabilities.builder()
+ .experimental(Map.of("feature", "test"))
+ .roots(true)
+ .sampling()
+ .build();
+
+ Function> samplingHandler = request -> Mono
+ .just(CreateMessageResult.builder().message("test").model("test-model").build());
+
+ withClient(createMcpTransport(), builder -> builder.capabilities(capabilities).sampling(samplingHandler),
+ client ->
+
+ StepVerifier.create(client.initialize()).assertNext(result -> {
+ assertThat(result).isNotNull();
+ assertThat(result.capabilities()).isNotNull();
+ }).verifyComplete());
+ }
+
+ // ---------------------------------------
+ // Logging Tests
+ // ---------------------------------------
+
+ @Test
+ void testLoggingLevelsWithoutInitialization() {
+ verifyInitializationTimeout(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG),
+ "setting logging level");
+ }
+
+ @Test
+ void testLoggingLevels() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> {
+ Mono chain = Mono.empty();
+ for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
+ chain = chain.then(mcpAsyncClient.setLoggingLevel(level));
+ }
+ return chain;
+ }));
+
+ StepVerifier.create(testAllLevels).verifyComplete();
+ });
+ }
+
+ @Test
+ void testLoggingConsumer() {
+ AtomicBoolean logReceived = new AtomicBoolean(false);
+
+ withClient(createMcpTransport(),
+ builder -> builder.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))),
+ client -> {
+ StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete();
+ StepVerifier.create(client.closeGracefully()).verifyComplete();
+
+ });
+
+ }
+
+ @Test
+ void testLoggingWithNullNotification() {
+ withClient(createMcpTransport(), mcpAsyncClient -> {
+ StepVerifier.create(mcpAsyncClient.setLoggingLevel(null))
+ .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null"))
+ .verify();
+ });
+ }
+
+}
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
new file mode 100644
index 000000000..128441f80
--- /dev/null
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
@@ -0,0 +1,449 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.ListResourceTemplatesResult;
+import io.modelcontextprotocol.spec.McpSchema.ListResourcesResult;
+import io.modelcontextprotocol.spec.McpSchema.ListToolsResult;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.Root;
+import io.modelcontextprotocol.spec.McpSchema.SubscribeRequest;
+import io.modelcontextprotocol.spec.McpSchema.TextContent;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+import reactor.test.StepVerifier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for MCP Client Session functionality.
+ *
+ * @author Christian Tzolov
+ * @author Dariusz Jędrzejczyk
+ */
+public abstract class AbstractMcpSyncClientTests {
+
+ private static final String TEST_MESSAGE = "Hello MCP Spring AI!";
+
+ abstract protected McpClientTransport createMcpTransport();
+
+ protected void onStart() {
+ }
+
+ protected void onClose() {
+ }
+
+ protected Duration getRequestTimeout() {
+ return Duration.ofSeconds(14);
+ }
+
+ protected Duration getInitializationTimeout() {
+ return Duration.ofSeconds(2);
+ }
+
+ McpSyncClient client(McpClientTransport transport) {
+ return client(transport, Function.identity());
+ }
+
+ McpSyncClient client(McpClientTransport transport, Function customizer) {
+ AtomicReference client = new AtomicReference<>();
+
+ assertThatCode(() -> {
+ McpClient.SyncSpec builder = McpClient.sync(transport)
+ .requestTimeout(getRequestTimeout())
+ .initializationTimeout(getInitializationTimeout())
+ .capabilities(ClientCapabilities.builder().roots(true).build());
+ builder = customizer.apply(builder);
+ client.set(builder.build());
+ }).doesNotThrowAnyException();
+
+ return client.get();
+ }
+
+ void withClient(McpClientTransport transport, Consumer c) {
+ withClient(transport, Function.identity(), c);
+ }
+
+ void withClient(McpClientTransport transport, Function customizer,
+ Consumer c) {
+ var client = client(transport, customizer);
+ try {
+ c.accept(client);
+ }
+ finally {
+ assertThat(client.closeGracefully()).isTrue();
+ }
+ }
+
+ @BeforeEach
+ void setUp() {
+ onStart();
+
+ }
+
+ @AfterEach
+ void tearDown() {
+ onClose();
+ }
+
+ static final Object DUMMY_RETURN_VALUE = new Object();
+
+ void verifyNotificationTimesOut(Consumer operation, String action) {
+ verifyCallTimesOut(client -> {
+ operation.accept(client);
+ return DUMMY_RETURN_VALUE;
+ }, action);
+ }
+
+ void verifyCallTimesOut(Function blockingOperation, String action) {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ // This scheduler is not replaced by virtual time scheduler
+ Scheduler customScheduler = Schedulers.newBoundedElastic(1, 1, "actualBoundedElastic");
+
+ StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient))
+ // Offload the blocking call to the real scheduler
+ .subscribeOn(customScheduler))
+ .expectSubscription()
+ // This works without actually waiting but executes all the
+ // tasks pending execution on the VirtualTimeScheduler.
+ // It is possible to execute the blocking code from the operation
+ // because it is blocked on a dedicated Scheduler and the main
+ // flow is not blocked and uses the VirtualTimeScheduler.
+ .thenAwait(getInitializationTimeout())
+ .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
+ .hasMessage("Client must be initialized before " + action))
+ .verify();
+
+ customScheduler.dispose();
+ });
+ }
+
+ @Test
+ void testConstructorWithInvalidArguments() {
+ assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Transport must not be null");
+
+ assertThatThrownBy(() -> McpClient.sync(createMcpTransport()).requestTimeout(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Request timeout must not be null");
+ }
+
+ @Test
+ void testListToolsWithoutInitialization() {
+ verifyCallTimesOut(client -> client.listTools(null), "listing tools");
+ }
+
+ @Test
+ void testListTools() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ ListToolsResult tools = mcpSyncClient.listTools(null);
+
+ assertThat(tools).isNotNull().satisfies(result -> {
+ assertThat(result.tools()).isNotNull().isNotEmpty();
+
+ Tool firstTool = result.tools().get(0);
+ assertThat(firstTool.name()).isNotNull();
+ assertThat(firstTool.description()).isNotNull();
+ });
+ });
+ }
+
+ @Test
+ void testCallToolsWithoutInitialization() {
+ verifyCallTimesOut(client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))),
+ "calling tools");
+ }
+
+ @Test
+ void testCallTools() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)));
+
+ assertThat(toolResult).isNotNull().satisfies(result -> {
+
+ assertThat(result.content()).hasSize(1);
+
+ TextContent content = (TextContent) result.content().get(0);
+
+ assertThat(content).isNotNull();
+ assertThat(content.text()).isNotNull();
+ assertThat(content.text()).contains("7");
+ });
+ });
+ }
+
+ @Test
+ void testPingWithoutInitialization() {
+ verifyCallTimesOut(client -> client.ping(), "pinging the server");
+ }
+
+ @Test
+ void testPing() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException();
+ });
+ }
+
+ @Test
+ void testCallToolWithoutInitialization() {
+ CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
+ verifyCallTimesOut(client -> client.callTool(callToolRequest), "calling tools");
+ }
+
+ @Test
+ void testCallTool() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
+
+ CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest);
+
+ assertThat(callToolResult).isNotNull().satisfies(result -> {
+ assertThat(result.content()).isNotNull();
+ assertThat(result.isError()).isNull();
+ });
+ });
+ }
+
+ @Test
+ void testCallToolWithInvalidTool() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE));
+
+ assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class);
+ });
+ }
+
+ @Test
+ void testRootsListChangedWithoutInitialization() {
+ verifyNotificationTimesOut(client -> client.rootsListChangedNotification(),
+ "sending roots list changed notification");
+ }
+
+ @Test
+ void testRootsListChanged() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException();
+ });
+ }
+
+ @Test
+ void testListResourcesWithoutInitialization() {
+ verifyCallTimesOut(client -> client.listResources(null), "listing resources");
+ }
+
+ @Test
+ void testListResources() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ ListResourcesResult resources = mcpSyncClient.listResources(null);
+
+ assertThat(resources).isNotNull().satisfies(result -> {
+ assertThat(result.resources()).isNotNull();
+
+ if (!result.resources().isEmpty()) {
+ Resource firstResource = result.resources().get(0);
+ assertThat(firstResource.uri()).isNotNull();
+ assertThat(firstResource.name()).isNotNull();
+ }
+ });
+ });
+ }
+
+ @Test
+ void testClientSessionState() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ assertThat(mcpSyncClient).isNotNull();
+ });
+ }
+
+ @Test
+ void testInitializeWithRootsListProviders() {
+ withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")),
+ mcpSyncClient -> {
+
+ assertThatCode(() -> {
+ mcpSyncClient.initialize();
+ mcpSyncClient.close();
+ }).doesNotThrowAnyException();
+ });
+ }
+
+ @Test
+ void testAddRoot() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ Root newRoot = new Root("file:///new/test/path", "new-test-root");
+ assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException();
+ });
+ }
+
+ @Test
+ void testAddRootWithNullValue() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null");
+ });
+ }
+
+ @Test
+ void testRemoveRoot() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ Root root = new Root("file:///test/path/to/remove", "root-to-remove");
+ assertThatCode(() -> {
+ mcpSyncClient.addRoot(root);
+ mcpSyncClient.removeRoot(root.uri());
+ }).doesNotThrowAnyException();
+ });
+ }
+
+ @Test
+ void testRemoveNonExistentRoot() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri"))
+ .hasMessageContaining("Root with uri 'nonexistent-uri' not found");
+ });
+ }
+
+ @Test
+ void testReadResourceWithoutInitialization() {
+ Resource resource = new Resource("test://uri", "Test Resource", null, null, null);
+ verifyCallTimesOut(client -> client.readResource(resource), "reading resources");
+ }
+
+ @Test
+ void testReadResource() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ ListResourcesResult resources = mcpSyncClient.listResources(null);
+
+ if (!resources.resources().isEmpty()) {
+ Resource firstResource = resources.resources().get(0);
+ ReadResourceResult result = mcpSyncClient.readResource(firstResource);
+
+ assertThat(result).isNotNull();
+ assertThat(result.contents()).isNotNull();
+ }
+ });
+ }
+
+ @Test
+ void testListResourceTemplatesWithoutInitialization() {
+ verifyCallTimesOut(client -> client.listResourceTemplates(null), "listing resource templates");
+ }
+
+ @Test
+ void testListResourceTemplates() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.resourceTemplates()).isNotNull();
+ });
+ }
+
+ // @Test
+ void testResourceSubscription() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ ListResourcesResult resources = mcpSyncClient.listResources(null);
+
+ if (!resources.resources().isEmpty()) {
+ Resource firstResource = resources.resources().get(0);
+
+ // Test subscribe
+ assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())))
+ .doesNotThrowAnyException();
+
+ // Test unsubscribe
+ assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri())))
+ .doesNotThrowAnyException();
+ }
+ });
+ }
+
+ @Test
+ void testNotificationHandlers() {
+ AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false);
+ AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false);
+ AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
+
+ withClient(createMcpTransport(),
+ builder -> builder.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
+ .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
+ .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)),
+ client -> {
+
+ assertThatCode(() -> {
+ client.initialize();
+ client.close();
+ }).doesNotThrowAnyException();
+ });
+ }
+
+ // ---------------------------------------
+ // Logging Tests
+ // ---------------------------------------
+
+ @Test
+ void testLoggingLevelsWithoutInitialization() {
+ verifyNotificationTimesOut(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG),
+ "setting logging level");
+ }
+
+ @Test
+ void testLoggingLevels() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ // Test all logging levels
+ for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
+ assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException();
+ }
+ });
+ }
+
+ @Test
+ void testLoggingConsumer() {
+ AtomicBoolean logReceived = new AtomicBoolean(false);
+ withClient(createMcpTransport(), builder -> builder.requestTimeout(getRequestTimeout())
+ .loggingConsumer(notification -> logReceived.set(true)), client -> {
+ assertThatCode(() -> {
+ client.initialize();
+ client.close();
+ }).doesNotThrowAnyException();
+ });
+ }
+
+ @Test
+ void testLoggingWithNullNotification() {
+ withClient(createMcpTransport(), mcpSyncClient -> assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null))
+ .hasMessageContaining("Logging level must not be null"));
+ }
+
+}
diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerDeprecatedTests.java
similarity index 91%
rename from mcp-test/src/main/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java
rename to mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerDeprecatedTests.java
index 0c7c1aa58..005d78f25 100644
--- a/mcp-test/src/main/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerDeprecatedTests.java
@@ -1,43 +1,30 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+package io.modelcontextprotocol.server;
import java.time.Duration;
import java.util.List;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpTransport;
+import io.modelcontextprotocol.spec.ServerMcpTransport;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
-import org.springframework.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
-import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
-import org.springframework.ai.mcp.spec.McpSchema.Prompt;
-import org.springframework.ai.mcp.spec.McpSchema.PromptMessage;
-import org.springframework.ai.mcp.spec.McpSchema.ReadResourceResult;
-import org.springframework.ai.mcp.spec.McpSchema.Resource;
-import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.Tool;
-import org.springframework.ai.mcp.spec.McpTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
-
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -48,7 +35,8 @@
*
* @author Christian Tzolov
*/
-public abstract class AbstractMcpAsyncServerTests {
+@Deprecated
+public abstract class AbstractMcpAsyncServerDeprecatedTests {
private static final String TEST_TOOL_NAME = "test-tool";
@@ -79,7 +67,8 @@ void tearDown() {
@Test
void testConstructorWithInvalidArguments() {
- assertThatThrownBy(() -> McpServer.async(null)).isInstanceOf(IllegalArgumentException.class)
+ assertThatThrownBy(() -> McpServer.async((ServerMcpTransport) null))
+ .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpServer.async(createMcpTransport()).serverInfo((McpSchema.Implementation) null))
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java
new file mode 100644
index 000000000..7bcb9a8b2
--- /dev/null
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java
@@ -0,0 +1,468 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import java.time.Duration;
+import java.util.List;
+
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Test suite for the {@link McpAsyncServer} that can be used with different
+ * {@link McpTransportProvider} implementations.
+ *
+ * @author Christian Tzolov
+ */
+// KEEP IN SYNC with the class in mcp-test module
+public abstract class AbstractMcpAsyncServerTests {
+
+ private static final String TEST_TOOL_NAME = "test-tool";
+
+ private static final String TEST_RESOURCE_URI = "test://resource";
+
+ private static final String TEST_PROMPT_NAME = "test-prompt";
+
+ abstract protected McpServerTransportProvider createMcpTransportProvider();
+
+ protected void onStart() {
+ }
+
+ protected void onClose() {
+ }
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ @AfterEach
+ void tearDown() {
+ onClose();
+ }
+
+ // ---------------------------------------
+ // Server Lifecycle Tests
+ // ---------------------------------------
+
+ @Test
+ void testConstructorWithInvalidArguments() {
+ assertThatThrownBy(() -> McpServer.async((McpServerTransportProvider) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Transport provider must not be null");
+
+ assertThatThrownBy(
+ () -> McpServer.async(createMcpTransportProvider()).serverInfo((McpSchema.Implementation) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Server info must not be null");
+ }
+
+ @Test
+ void testGracefulShutdown() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void testImmediateClose() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Tools Tests
+ // ---------------------------------------
+ String emptyJsonSchema = """
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {}
+ }
+ """;
+
+ @Test
+ void testAddTool() {
+ Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolSpecification(newTool,
+ (excnage, args) -> Mono.just(new CallToolResult(List.of(), false)))))
+ .verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddDuplicateTool() {
+ Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
+
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tool(duplicateTool, (exchange, args) -> Mono.just(new CallToolResult(List.of(), false)))
+ .build();
+
+ StepVerifier
+ .create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolSpecification(duplicateTool,
+ (exchange, args) -> Mono.just(new CallToolResult(List.of(), false)))))
+ .verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class)
+ .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists");
+ });
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRemoveTool() {
+ Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
+
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tool(too, (exchange, args) -> Mono.just(new CallToolResult(List.of(), false)))
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRemoveNonexistentTool() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class).hasMessage("Tool with name 'nonexistent-tool' not found");
+ });
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testNotifyToolsListChanged() {
+ Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
+
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tool(too, (exchange, args) -> Mono.just(new CallToolResult(List.of(), false)))
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Resources Tests
+ // ---------------------------------------
+
+ @Test
+ void testNotifyResourcesListChanged() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddResource() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().resources(true, false).build())
+ .build();
+
+ Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
+ null);
+ McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification(
+ resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of())));
+
+ StepVerifier.create(mcpAsyncServer.addResource(specification)).verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddResourceWithNullSpecification() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().resources(true, false).build())
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceSpecification) null))
+ .verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null");
+ });
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddResourceWithoutCapability() {
+ // Create a server without resource capabilities
+ McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
+ null);
+ McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification(
+ resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of())));
+
+ StepVerifier.create(serverWithoutResources.addResource(specification)).verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with resource capabilities");
+ });
+ }
+
+ @Test
+ void testRemoveResourceWithoutCapability() {
+ // Create a server without resource capabilities
+ McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with resource capabilities");
+ });
+ }
+
+ // ---------------------------------------
+ // Prompts Tests
+ // ---------------------------------------
+
+ @Test
+ void testNotifyPromptsListChanged() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddPromptWithNullSpecification() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().prompts(false).build())
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptSpecification) null))
+ .verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt specification must not be null");
+ });
+ }
+
+ @Test
+ void testAddPromptWithoutCapability() {
+ // Create a server without prompt capabilities
+ McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
+ McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification(
+ prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List
+ .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
+
+ StepVerifier.create(serverWithoutPrompts.addPrompt(specification)).verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with prompt capabilities");
+ });
+ }
+
+ @Test
+ void testRemovePromptWithoutCapability() {
+ // Create a server without prompt capabilities
+ McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with prompt capabilities");
+ });
+ }
+
+ @Test
+ void testRemovePrompt() {
+ String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678";
+
+ Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", List.of());
+ McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification(
+ prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List
+ .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
+
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().prompts(true).build())
+ .prompts(specification)
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete();
+
+ assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRemoveNonexistentPrompt() {
+ var mcpAsyncServer2 = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().prompts(true).build())
+ .build();
+
+ StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyErrorSatisfies(error -> {
+ assertThat(error).isInstanceOf(McpError.class)
+ .hasMessage("Prompt with name 'nonexistent-prompt' not found");
+ });
+
+ assertThatCode(() -> mcpAsyncServer2.closeGracefully().block(Duration.ofSeconds(10)))
+ .doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Roots Tests
+ // ---------------------------------------
+
+ @Test
+ void testRootsChangeHandlers() {
+ // Test with single consumer
+ var rootsReceived = new McpSchema.Root[1];
+ var consumerCalled = new boolean[1];
+
+ var singleConsumerServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> {
+ consumerCalled[0] = true;
+ if (!roots.isEmpty()) {
+ rootsReceived[0] = roots.get(0);
+ }
+ })))
+ .build();
+
+ assertThat(singleConsumerServer).isNotNull();
+ assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10)))
+ .doesNotThrowAnyException();
+ onClose();
+
+ // Test with multiple consumers
+ var consumer1Called = new boolean[1];
+ var consumer2Called = new boolean[1];
+ var rootsContent = new List[1];
+
+ var multipleConsumersServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> {
+ consumer1Called[0] = true;
+ rootsContent[0] = roots;
+ }), (exchange, roots) -> Mono.fromRunnable(() -> consumer2Called[0] = true)))
+ .build();
+
+ assertThat(multipleConsumersServer).isNotNull();
+ assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
+ .doesNotThrowAnyException();
+ onClose();
+
+ // Test error handling
+ var errorHandlingServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .rootsChangeHandlers(List.of((exchange, roots) -> {
+ throw new RuntimeException("Test error");
+ }))
+ .build();
+
+ assertThat(errorHandlingServer).isNotNull();
+ assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10)))
+ .doesNotThrowAnyException();
+ onClose();
+
+ // Test without consumers
+ var noConsumersServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ assertThat(noConsumersServer).isNotNull();
+ assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
+ .doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Logging Tests
+ // ---------------------------------------
+
+ @Test
+ void testLoggingLevels() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().logging().build())
+ .build();
+
+ // Test all logging levels
+ for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
+ var notification = McpSchema.LoggingMessageNotification.builder()
+ .level(level)
+ .logger("test-logger")
+ .data("Test message with level " + level)
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
+ }
+ }
+
+ @Test
+ void testLoggingWithoutCapability() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().build()) // No logging capability
+ .build();
+
+ var notification = McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.INFO)
+ .logger("test-logger")
+ .data("Test log message")
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
+ }
+
+ @Test
+ void testLoggingWithNullNotification() {
+ var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().logging().build())
+ .build();
+
+ StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class);
+ }
+
+}
diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerDeprecatedTests.java
similarity index 90%
rename from mcp-test/src/main/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
rename to mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerDeprecatedTests.java
index ddb827a05..c6625acaa 100644
--- a/mcp-test/src/main/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerDeprecatedTests.java
@@ -1,40 +1,27 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+package io.modelcontextprotocol.server;
import java.util.List;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpTransport;
+import io.modelcontextprotocol.spec.ServerMcpTransport;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.springframework.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
-import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
-import org.springframework.ai.mcp.spec.McpSchema.Prompt;
-import org.springframework.ai.mcp.spec.McpSchema.PromptMessage;
-import org.springframework.ai.mcp.spec.McpSchema.ReadResourceResult;
-import org.springframework.ai.mcp.spec.McpSchema.Resource;
-import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.Tool;
-import org.springframework.ai.mcp.spec.McpTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
-
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -45,7 +32,7 @@
*
* @author Christian Tzolov
*/
-public abstract class AbstractMcpSyncServerTests {
+public abstract class AbstractMcpSyncServerDeprecatedTests {
private static final String TEST_TOOL_NAME = "test-tool";
@@ -77,7 +64,7 @@ void tearDown() {
@Test
void testConstructorWithInvalidArguments() {
- assertThatThrownBy(() -> McpServer.sync(null)).isInstanceOf(IllegalArgumentException.class)
+ assertThatThrownBy(() -> McpServer.sync((ServerMcpTransport) null)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpServer.sync(createMcpTransport()).serverInfo(null))
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java
new file mode 100644
index 000000000..7846e053b
--- /dev/null
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java
@@ -0,0 +1,440 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import java.util.List;
+
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
+import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Test suite for the {@link McpSyncServer} that can be used with different
+ * {@link McpTransportProvider} implementations.
+ *
+ * @author Christian Tzolov
+ */
+// KEEP IN SYNC with the class in mcp-test module
+public abstract class AbstractMcpSyncServerTests {
+
+ private static final String TEST_TOOL_NAME = "test-tool";
+
+ private static final String TEST_RESOURCE_URI = "test://resource";
+
+ private static final String TEST_PROMPT_NAME = "test-prompt";
+
+ abstract protected McpServerTransportProvider createMcpTransportProvider();
+
+ protected void onStart() {
+ }
+
+ protected void onClose() {
+ }
+
+ @BeforeEach
+ void setUp() {
+ // onStart();
+ }
+
+ @AfterEach
+ void tearDown() {
+ onClose();
+ }
+
+ // ---------------------------------------
+ // Server Lifecycle Tests
+ // ---------------------------------------
+
+ @Test
+ void testConstructorWithInvalidArguments() {
+ assertThatThrownBy(() -> McpServer.sync((McpServerTransportProvider) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Transport provider must not be null");
+
+ assertThatThrownBy(() -> McpServer.sync(createMcpTransportProvider()).serverInfo(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Server info must not be null");
+ }
+
+ @Test
+ void testGracefulShutdown() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testImmediateClose() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testGetAsyncServer() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Tools Tests
+ // ---------------------------------------
+
+ String emptyJsonSchema = """
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {}
+ }
+ """;
+
+ @Test
+ void testAddTool() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
+ assertThatCode(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(newTool,
+ (exchange, args) -> new CallToolResult(List.of(), false))))
+ .doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddDuplicateTool() {
+ Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
+
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tool(duplicateTool, (exchange, args) -> new CallToolResult(List.of(), false))
+ .build();
+
+ assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(duplicateTool,
+ (exchange, args) -> new CallToolResult(List.of(), false))))
+ .isInstanceOf(McpError.class)
+ .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists");
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRemoveTool() {
+ Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema);
+
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tool(tool, (exchange, args) -> new CallToolResult(List.of(), false))
+ .build();
+
+ assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRemoveNonexistentTool() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ assertThatThrownBy(() -> mcpSyncServer.removeTool("nonexistent-tool")).isInstanceOf(McpError.class)
+ .hasMessage("Tool with name 'nonexistent-tool' not found");
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testNotifyToolsListChanged() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Resources Tests
+ // ---------------------------------------
+
+ @Test
+ void testNotifyResourcesListChanged() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddResource() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().resources(true, false).build())
+ .build();
+
+ Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
+ null);
+ McpServerFeatures.SyncResourceSpecification specificaiton = new McpServerFeatures.SyncResourceSpecification(
+ resource, (exchange, req) -> new ReadResourceResult(List.of()));
+
+ assertThatCode(() -> mcpSyncServer.addResource(specificaiton)).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddResourceWithNullSpecifiation() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().resources(true, false).build())
+ .build();
+
+ assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceSpecification) null))
+ .isInstanceOf(McpError.class)
+ .hasMessage("Resource must not be null");
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddResourceWithoutCapability() {
+ var serverWithoutResources = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
+ null);
+ McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification(
+ resource, (exchange, req) -> new ReadResourceResult(List.of()));
+
+ assertThatThrownBy(() -> serverWithoutResources.addResource(specification)).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with resource capabilities");
+ }
+
+ @Test
+ void testRemoveResourceWithoutCapability() {
+ var serverWithoutResources = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with resource capabilities");
+ }
+
+ // ---------------------------------------
+ // Prompts Tests
+ // ---------------------------------------
+
+ @Test
+ void testNotifyPromptsListChanged() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testAddPromptWithNullSpecification() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().prompts(false).build())
+ .build();
+
+ assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptSpecification) null))
+ .isInstanceOf(McpError.class)
+ .hasMessage("Prompt specification must not be null");
+ }
+
+ @Test
+ void testAddPromptWithoutCapability() {
+ var serverWithoutPrompts = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
+ McpServerFeatures.SyncPromptSpecification specificaiton = new McpServerFeatures.SyncPromptSpecification(prompt,
+ (exchange, req) -> new GetPromptResult("Test prompt description", List
+ .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
+
+ assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(specificaiton)).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with prompt capabilities");
+ }
+
+ @Test
+ void testRemovePromptWithoutCapability() {
+ var serverWithoutPrompts = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .build();
+
+ assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class)
+ .hasMessage("Server must be configured with prompt capabilities");
+ }
+
+ @Test
+ void testRemovePrompt() {
+ Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
+ McpServerFeatures.SyncPromptSpecification specificaiton = new McpServerFeatures.SyncPromptSpecification(prompt,
+ (exchange, req) -> new GetPromptResult("Test prompt description", List
+ .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
+
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().prompts(true).build())
+ .prompts(specificaiton)
+ .build();
+
+ assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRemoveNonexistentPrompt() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().prompts(true).build())
+ .build();
+
+ assertThatThrownBy(() -> mcpSyncServer.removePrompt("nonexistent-prompt")).isInstanceOf(McpError.class)
+ .hasMessage("Prompt with name 'nonexistent-prompt' not found");
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Roots Tests
+ // ---------------------------------------
+
+ @Test
+ void testRootsChangeHandlers() {
+ // Test with single consumer
+ var rootsReceived = new McpSchema.Root[1];
+ var consumerCalled = new boolean[1];
+
+ var singleConsumerServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .rootsChangeHandlers(List.of((exchage, roots) -> {
+ consumerCalled[0] = true;
+ if (!roots.isEmpty()) {
+ rootsReceived[0] = roots.get(0);
+ }
+ }))
+ .build();
+
+ assertThat(singleConsumerServer).isNotNull();
+ assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException();
+ onClose();
+
+ // Test with multiple consumers
+ var consumer1Called = new boolean[1];
+ var consumer2Called = new boolean[1];
+ var rootsContent = new List[1];
+
+ var multipleConsumersServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .rootsChangeHandlers(List.of((exchange, roots) -> {
+ consumer1Called[0] = true;
+ rootsContent[0] = roots;
+ }, (exchange, roots) -> consumer2Called[0] = true))
+ .build();
+
+ assertThat(multipleConsumersServer).isNotNull();
+ assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException();
+ onClose();
+
+ // Test error handling
+ var errorHandlingServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .rootsChangeHandlers(List.of((exchange, roots) -> {
+ throw new RuntimeException("Test error");
+ }))
+ .build();
+
+ assertThat(errorHandlingServer).isNotNull();
+ assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException();
+ onClose();
+
+ // Test without consumers
+ var noConsumersServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
+
+ assertThat(noConsumersServer).isNotNull();
+ assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ // ---------------------------------------
+ // Logging Tests
+ // ---------------------------------------
+
+ @Test
+ void testLoggingLevels() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().logging().build())
+ .build();
+
+ // Test all logging levels
+ for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
+ var notification = McpSchema.LoggingMessageNotification.builder()
+ .level(level)
+ .logger("test-logger")
+ .data("Test message with level " + level)
+ .build();
+
+ assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
+ }
+ }
+
+ @Test
+ void testLoggingWithoutCapability() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().build()) // No logging capability
+ .build();
+
+ var notification = McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.INFO)
+ .logger("test-logger")
+ .data("Test log message")
+ .build();
+
+ assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testLoggingWithNullNotification() {
+ var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().logging().build())
+ .build();
+
+ assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class);
+ }
+
+}
diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/MockMcpTransport.java b/mcp-test/src/main/java/org/springframework/ai/mcp/MockMcpTransport.java
deleted file mode 100644
index 20736b1a6..000000000
--- a/mcp-test/src/main/java/org/springframework/ai/mcp/MockMcpTransport.java
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.ai.mcp;
-
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Function;
-
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-import reactor.core.publisher.Sinks;
-import reactor.core.scheduler.Schedulers;
-
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
-
-@SuppressWarnings("unused")
-public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport {
-
- private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
-
- private final Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer();
-
- private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer();
-
- private final Flux outboundView = outgoing.asFlux().cache(1);
-
- public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
- if (inbound.tryEmitNext(message).isFailure()) {
- throw new RuntimeException("Failed to emit message " + message);
- }
- inboundMessageCount.incrementAndGet();
- }
-
- @Override
- public Mono sendMessage(McpSchema.JSONRPCMessage message) {
- if (outgoing.tryEmitNext(message).isFailure()) {
- return Mono.error(new RuntimeException("Can't emit outgoing message " + message));
- }
- return Mono.empty();
- }
-
- public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
- return (JSONRPCRequest) outboundView.blockFirst();
- }
-
- public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() {
- return (JSONRPCNotification) outboundView.blockFirst();
- }
-
- public McpSchema.JSONRPCMessage getLastSentMessage() {
- return outboundView.blockFirst();
- }
-
- private volatile boolean connected = false;
-
- @Override
- public Mono connect(Function, Mono> handler) {
- if (connected) {
- return Mono.error(new IllegalStateException("Already connected"));
- }
- connected = true;
- return inbound.asFlux()
- .publishOn(Schedulers.boundedElastic())
- .flatMap(message -> Mono.just(message).transform(handler))
- .doFinally(signal -> connected = false)
- .then();
- }
-
- @Override
- public Mono closeGracefully() {
- return Mono.defer(() -> {
- connected = false;
- outgoing.tryEmitComplete();
- inbound.tryEmitComplete();
- return Mono.empty();
- });
- }
-
- @Override
- public T unmarshalFrom(Object data, TypeReference typeRef) {
- return new ObjectMapper().convertValue(data, typeRef);
- }
-
-}
\ No newline at end of file
diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
deleted file mode 100644
index 2579f573c..000000000
--- a/mcp-test/src/main/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
+++ /dev/null
@@ -1,375 +0,0 @@
-/*
- * Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.ai.mcp.client;
-
-import java.time.Duration;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Function;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import reactor.core.publisher.Mono;
-import reactor.test.StepVerifier;
-
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
-import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult;
-import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
-import org.springframework.ai.mcp.spec.McpSchema.Prompt;
-import org.springframework.ai.mcp.spec.McpSchema.Resource;
-import org.springframework.ai.mcp.spec.McpSchema.Root;
-import org.springframework.ai.mcp.spec.McpSchema.SubscribeRequest;
-import org.springframework.ai.mcp.spec.McpSchema.Tool;
-import org.springframework.ai.mcp.spec.McpSchema.UnsubscribeRequest;
-import org.springframework.ai.mcp.spec.McpTransport;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-/**
- * Test suite for the {@link McpAsyncClient} that can be used with different
- * {@link McpTransport} implementations.
- *
- * @author Christian Tzolov
- * @author Dariusz Jędrzejczyk
- */
-public abstract class AbstractMcpAsyncClientTests {
-
- private McpAsyncClient mcpAsyncClient;
-
- protected ClientMcpTransport mcpTransport;
-
- private static final Duration TIMEOUT = Duration.ofSeconds(20);
-
- private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!";
-
- abstract protected ClientMcpTransport createMcpTransport();
-
- protected void onStart() {
- }
-
- protected void onClose() {
- }
-
- @BeforeEach
- void setUp() {
- onStart();
- this.mcpTransport = createMcpTransport();
-
- assertThatCode(() -> {
- mcpAsyncClient = McpClient.async(mcpTransport)
- .requestTimeout(TIMEOUT)
- .capabilities(ClientCapabilities.builder().roots(true).build())
- .build();
- mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
- }).doesNotThrowAnyException();
- }
-
- @AfterEach
- void tearDown() {
- if (mcpAsyncClient != null) {
- assertThatCode(() -> mcpAsyncClient.closeGracefully().block(Duration.ofSeconds(10)))
- .doesNotThrowAnyException();
- }
- onClose();
- }
-
- @Test
- void testConstructorWithInvalidArguments() {
- assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Transport must not be null");
-
- assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Request timeout must not be null");
- }
-
- @Test
- void testListTools() {
- StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> {
- assertThat(result.tools()).isNotNull().isNotEmpty();
-
- Tool firstTool = result.tools().get(0);
- assertThat(firstTool.name()).isNotNull();
- assertThat(firstTool.description()).isNotNull();
- }).verifyComplete();
- }
-
- @Test
- void testPing() {
- assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException();
- }
-
- @Test
- void testCallTool() {
- CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
-
- StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> {
- assertThat(callToolResult).isNotNull().satisfies(result -> {
- assertThat(result.content()).isNotNull();
- assertThat(result.isError()).isNull();
- });
- }).verifyComplete();
- }
-
- @Test
- void testCallToolWithInvalidTool() {
- CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE));
-
- assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
- }
-
- @Test
- void testListResources() {
- StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
- assertThat(resources).isNotNull().satisfies(result -> {
- assertThat(result.resources()).isNotNull();
-
- if (!result.resources().isEmpty()) {
- Resource firstResource = result.resources().get(0);
- assertThat(firstResource.uri()).isNotNull();
- assertThat(firstResource.name()).isNotNull();
- }
- });
- }).verifyComplete();
- }
-
- @Test
- void testMcpAsyncClientState() {
- assertThat(mcpAsyncClient).isNotNull();
- }
-
- @Test
- void testListPrompts() {
- StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> {
- assertThat(prompts).isNotNull().satisfies(result -> {
- assertThat(result.prompts()).isNotNull();
-
- if (!result.prompts().isEmpty()) {
- Prompt firstPrompt = result.prompts().get(0);
- assertThat(firstPrompt.name()).isNotNull();
- assertThat(firstPrompt.description()).isNotNull();
- }
- });
- }).verifyComplete();
- }
-
- @Test
- void testGetPrompt() {
- StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))
- .consumeNextWith(prompt -> {
- assertThat(prompt).isNotNull().satisfies(result -> {
- assertThat(result.messages()).isNotEmpty();
- assertThat(result.messages()).hasSize(1);
- });
- })
- .verifyComplete();
- }
-
- @Test
- void testRootsListChanged() {
- assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException();
- }
-
- @Test
- void testInitializeWithRootsListProviders() {
- var transport = createMcpTransport();
-
- var client = McpClient.async(transport)
- .requestTimeout(TIMEOUT)
- .roots(new Root("file:///test/path", "test-root"))
- .build();
-
- assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
-
- assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
- }
-
- @Test
- void testAddRoot() {
- Root newRoot = new Root("file:///new/test/path", "new-test-root");
- assertThatCode(() -> mcpAsyncClient.addRoot(newRoot).block()).doesNotThrowAnyException();
- }
-
- @Test
- void testAddRootWithNullValue() {
- assertThatThrownBy(() -> mcpAsyncClient.addRoot(null).block()).hasMessageContaining("Root must not be null");
- }
-
- @Test
- void testRemoveRoot() {
- Root root = new Root("file:///test/path/to/remove", "root-to-remove");
- assertThatCode(() -> {
- mcpAsyncClient.addRoot(root).block();
- mcpAsyncClient.removeRoot(root.uri()).block();
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testRemoveNonExistentRoot() {
- assertThatThrownBy(() -> mcpAsyncClient.removeRoot("nonexistent-uri").block())
- .hasMessageContaining("Root with uri 'nonexistent-uri' not found");
- }
-
- @Test
- @Disabled
- void testReadResource() {
- StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
- if (!resources.resources().isEmpty()) {
- Resource firstResource = resources.resources().get(0);
- StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> {
- assertThat(result).isNotNull();
- assertThat(result.contents()).isNotNull();
- }).verifyComplete();
- }
- }).verifyComplete();
- }
-
- @Test
- void testListResourceTemplates() {
- StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> {
- assertThat(result).isNotNull();
- assertThat(result.resourceTemplates()).isNotNull();
- }).verifyComplete();
- }
-
- // @Test
- void testResourceSubscription() {
- StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
- if (!resources.resources().isEmpty()) {
- Resource firstResource = resources.resources().get(0);
-
- // Test subscribe
- StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())))
- .verifyComplete();
-
- // Test unsubscribe
- StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri())))
- .verifyComplete();
- }
- }).verifyComplete();
- }
-
- @Test
- void testNotificationHandlers() {
- AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false);
- AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false);
- AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
-
- var transport = createMcpTransport();
- var client = McpClient.async(transport)
- .requestTimeout(TIMEOUT)
- .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
- .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
- .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true)))
- .build();
-
- assertThatCode(() -> {
- client.initialize().block();
- // Trigger notifications
- client.sendResourcesListChanged().block();
- client.promptListChangedNotification().block();
- client.closeGracefully().block();
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testInitializeWithSamplingCapability() {
- var transport = createMcpTransport();
-
- var capabilities = ClientCapabilities.builder().sampling().build();
-
- var client = McpClient.async(transport)
- .requestTimeout(TIMEOUT)
- .capabilities(capabilities)
- .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build()))
- .build();
-
- assertThatCode(() -> {
- client.initialize().block(Duration.ofSeconds(10));
- client.closeGracefully().block(Duration.ofSeconds(10));
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testInitializeWithAllCapabilities() {
- var transport = createMcpTransport();
-
- var capabilities = ClientCapabilities.builder()
- .experimental(Map.of("feature", "test"))
- .roots(true)
- .sampling()
- .build();
-
- Function> samplingHandler = request -> Mono
- .just(CreateMessageResult.builder().message("test").model("test-model").build());
- var client = McpClient.async(transport)
- .requestTimeout(TIMEOUT)
- .capabilities(capabilities)
- .sampling(samplingHandler)
- .build();
-
- assertThatCode(() -> {
- var result = client.initialize().block(Duration.ofSeconds(10));
- assertThat(result).isNotNull();
- assertThat(result.capabilities()).isNotNull();
- client.closeGracefully().block(Duration.ofSeconds(10));
- }).doesNotThrowAnyException();
- }
-
- // ---------------------------------------
- // Logging Tests
- // ---------------------------------------
-
- @Test
- void testLoggingLevels() {
- // Test all logging levels
- for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
- StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete();
- }
- }
-
- @Test
- void testLoggingConsumer() {
- AtomicBoolean logReceived = new AtomicBoolean(false);
- var transport = createMcpTransport();
-
- var client = McpClient.async(transport)
- .requestTimeout(TIMEOUT)
- .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true)))
- .build();
-
- assertThatCode(() -> {
- client.initialize().block(Duration.ofSeconds(10));
- client.closeGracefully().block(Duration.ofSeconds(10));
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testLoggingWithNullNotification() {
- assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(null).block())
- .hasMessageContaining("Logging level must not be null");
- }
-
-}
diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
deleted file mode 100644
index c0126125d..000000000
--- a/mcp-test/src/main/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
+++ /dev/null
@@ -1,315 +0,0 @@
-/*
- * Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.springframework.ai.mcp.client;
-
-import java.time.Duration;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
-import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.ListResourceTemplatesResult;
-import org.springframework.ai.mcp.spec.McpSchema.ListResourcesResult;
-import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult;
-import org.springframework.ai.mcp.spec.McpSchema.ReadResourceResult;
-import org.springframework.ai.mcp.spec.McpSchema.Resource;
-import org.springframework.ai.mcp.spec.McpSchema.Root;
-import org.springframework.ai.mcp.spec.McpSchema.SubscribeRequest;
-import org.springframework.ai.mcp.spec.McpSchema.TextContent;
-import org.springframework.ai.mcp.spec.McpSchema.Tool;
-import org.springframework.ai.mcp.spec.McpSchema.UnsubscribeRequest;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-/**
- * Unit tests for MCP Client Session functionality.
- *
- * @author Christian Tzolov
- * @author Dariusz Jędrzejczyk
- */
-public abstract class AbstractMcpSyncClientTests {
-
- private McpSyncClient mcpSyncClient;
-
- private static final Duration TIMEOUT = Duration.ofSeconds(10);
-
- private static final String TEST_MESSAGE = "Hello MCP Spring AI!";
-
- protected ClientMcpTransport mcpTransport;
-
- abstract protected ClientMcpTransport createMcpTransport();
-
- abstract protected void onStart();
-
- abstract protected void onClose();
-
- @BeforeEach
- void setUp() {
- onStart();
- this.mcpTransport = createMcpTransport();
-
- assertThatCode(() -> {
- mcpSyncClient = McpClient.sync(mcpTransport)
- .requestTimeout(TIMEOUT)
- .capabilities(ClientCapabilities.builder().roots(true).build())
- .build();
- mcpSyncClient.initialize();
- }).doesNotThrowAnyException();
- }
-
- @AfterEach
- void tearDown() {
- if (mcpSyncClient != null) {
- assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException();
- }
- onClose();
- }
-
- @Test
- void testConstructorWithInvalidArguments() {
- assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Transport must not be null");
-
- assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build())
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Request timeout must not be null");
- }
-
- @Test
- void testListTools() {
- ListToolsResult tools = mcpSyncClient.listTools(null);
-
- assertThat(tools).isNotNull().satisfies(result -> {
- assertThat(result.tools()).isNotNull().isNotEmpty();
-
- Tool firstTool = result.tools().get(0);
- assertThat(firstTool.name()).isNotNull();
- assertThat(firstTool.description()).isNotNull();
- });
- }
-
- @Test
- void testCallTools() {
- CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)));
-
- assertThat(toolResult).isNotNull().satisfies(result -> {
-
- assertThat(result.content()).hasSize(1);
-
- TextContent content = (TextContent) result.content().get(0);
-
- assertThat(content).isNotNull();
- assertThat(content.text()).isNotNull();
- assertThat(content.text()).contains("7");
- });
- }
-
- @Test
- void testPing() {
- assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException();
- }
-
- @Test
- void testCallTool() {
- CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
-
- CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest);
-
- assertThat(callToolResult).isNotNull().satisfies(result -> {
- assertThat(result.content()).isNotNull();
- assertThat(result.isError()).isNull();
- });
- }
-
- @Test
- void testCallToolWithInvalidTool() {
- CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE));
-
- assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class);
- }
-
- @Test
- void testRootsListChanged() {
- assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException();
- }
-
- @Test
- void testListResources() {
- ListResourcesResult resources = mcpSyncClient.listResources(null);
-
- assertThat(resources).isNotNull().satisfies(result -> {
- assertThat(result.resources()).isNotNull();
-
- if (!result.resources().isEmpty()) {
- Resource firstResource = result.resources().get(0);
- assertThat(firstResource.uri()).isNotNull();
- assertThat(firstResource.name()).isNotNull();
- }
- });
- }
-
- @Test
- void testClientSessionState() {
- assertThat(mcpSyncClient).isNotNull();
- }
-
- @Test
- void testInitializeWithRootsListProviders() {
- var transport = createMcpTransport();
-
- var client = McpClient.sync(transport)
- .requestTimeout(TIMEOUT)
- .roots(new Root("file:///test/path", "test-root"))
- .build();
-
- assertThatCode(() -> {
- client.initialize();
- client.close();
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testAddRoot() {
- Root newRoot = new Root("file:///new/test/path", "new-test-root");
- assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException();
- }
-
- @Test
- void testAddRootWithNullValue() {
- assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null");
- }
-
- @Test
- void testRemoveRoot() {
- Root root = new Root("file:///test/path/to/remove", "root-to-remove");
- assertThatCode(() -> {
- mcpSyncClient.addRoot(root);
- mcpSyncClient.removeRoot(root.uri());
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testRemoveNonExistentRoot() {
- assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri"))
- .hasMessageContaining("Root with uri 'nonexistent-uri' not found");
- }
-
- @Test
- void testReadResource() {
- ListResourcesResult resources = mcpSyncClient.listResources(null);
-
- if (!resources.resources().isEmpty()) {
- Resource firstResource = resources.resources().get(0);
- ReadResourceResult result = mcpSyncClient.readResource(firstResource);
-
- assertThat(result).isNotNull();
- assertThat(result.contents()).isNotNull();
- }
- }
-
- @Test
- void testListResourceTemplates() {
- ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null);
-
- assertThat(result).isNotNull();
- assertThat(result.resourceTemplates()).isNotNull();
- }
-
- // @Test
- void testResourceSubscription() {
- ListResourcesResult resources = mcpSyncClient.listResources(null);
-
- if (!resources.resources().isEmpty()) {
- Resource firstResource = resources.resources().get(0);
-
- // Test subscribe
- assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())))
- .doesNotThrowAnyException();
-
- // Test unsubscribe
- assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri())))
- .doesNotThrowAnyException();
- }
- }
-
- @Test
- void testNotificationHandlers() {
- AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false);
- AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false);
- AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false);
-
- var transport = createMcpTransport();
- var client = McpClient.sync(transport)
- .requestTimeout(TIMEOUT)
- .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
- .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
- .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
- .build();
-
- assertThatCode(() -> {
- client.initialize();
- // Trigger notifications
- client.sendResourcesListChanged();
- client.promptListChangedNotification();
- client.close();
- }).doesNotThrowAnyException();
- }
-
- // ---------------------------------------
- // Logging Tests
- // ---------------------------------------
-
- @Test
- void testLoggingLevels() {
- // Test all logging levels
- for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
- assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException();
- }
- }
-
- @Test
- void testLoggingConsumer() {
- AtomicBoolean logReceived = new AtomicBoolean(false);
- var transport = createMcpTransport();
-
- var client = McpClient.sync(transport)
- .requestTimeout(TIMEOUT)
- .loggingConsumer(notification -> logReceived.set(true))
- .build();
-
- assertThatCode(() -> {
- client.initialize();
- client.close();
- }).doesNotThrowAnyException();
- }
-
- @Test
- void testLoggingWithNullNotification() {
- assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null))
- .hasMessageContaining("Logging level must not be null");
- }
-
-}
diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/_SseServerTransportTests.java_ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/_SseServerTransportTests.java_
deleted file mode 100644
index 80f31a227..000000000
--- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/_SseServerTransportTests.java_
+++ /dev/null
@@ -1,283 +0,0 @@
-package org.springframework.ai.mcp.server.transport;
-
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicReference;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.Timeout;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import reactor.core.Disposable;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-import reactor.test.StepVerifier;
-
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.http.codec.ServerSentEvent;
-import org.springframework.test.web.reactive.server.WebTestClient;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-/**
- * Tests for the {@link SseServerTransport} class.
- *
- * @author Christian Tzolov
- */
-@Timeout(15)
-class SseServerTransportTests {
-
- private static final Logger logger = LoggerFactory.getLogger(SseServerTransportTests.class);
-
- private ObjectMapper objectMapper;
-
- private String messageEndpoint;
-
- private SseServerTransport transport;
-
- private WebTestClient webTestClient;
-
- @BeforeEach
- void setUp() {
- objectMapper = new ObjectMapper();
- messageEndpoint = "/message";
- transport = new SseServerTransport(objectMapper, messageEndpoint);
- webTestClient = WebTestClient.bindToRouterFunction(transport.getRouterFunction()).build();
- }
-
- @Test
- void constructorValidation() {
- assertThatThrownBy(() -> new SseServerTransport(null, "/message")).isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("ObjectMapper must not be null");
-
- assertThatThrownBy(() -> new SseServerTransport(new ObjectMapper(), null))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Message endpoint must not be null");
- }
-
- @Test
- void testSseConnectionEstablishment() {
- List> events = new ArrayList<>();
-
- webTestClient.get()
- .uri("/sse")
- .accept(MediaType.TEXT_EVENT_STREAM)
- .exchange()
- .expectStatus()
- .isOk()
- .expectHeader()
- .contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
- .returnResult(String.class)
- .getResponseBody()
- .map(data -> ServerSentEvent.builder().data(data).build())
- .take(1) // Take only the initial endpoint event
- .subscribe(events::add);
-
- // Wait a bit for the event to be received
- StepVerifier.create(Mono.delay(Duration.ofMillis(500))).expectNextCount(1).verifyComplete();
-
- assertThat(events).hasSize(1);
- assertThat(events.get(0).data()).isEqualTo(messageEndpoint);
- }
-
- @Test
- void testMessageHandling() {
- AtomicReference receivedMessage = new AtomicReference<>();
-
- // Set up message handler
- transport.connect(message -> {
- message.doOnNext(receivedMessage::set).subscribe();
- return Mono.empty();
- }).block();
-
- // Create a test message
- JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
- Map.of("key", "value"));
-
- // Send message to endpoint
- webTestClient.post()
- .uri(messageEndpoint)
- .contentType(MediaType.APPLICATION_JSON)
- .bodyValue(testMessage)
- .exchange()
- .expectStatus()
- .isOk();
-
- // Verify the message was received and processed
- assertThat(receivedMessage.get()).isNotNull();
- McpSchema.JSONRPCRequest receivedRequest = (McpSchema.JSONRPCRequest) receivedMessage.get();
- assertThat(receivedRequest.id()).isEqualTo(testMessage.id());
- assertThat(receivedRequest.method()).isEqualTo(testMessage.method());
- }
-
- @Test
- @Disabled("Flaky test")
- void testBroadcastMessage() {
- // Create test clients
- int clientCount = 3;
- CountDownLatch connectLatch = new CountDownLatch(clientCount);
- CountDownLatch messageLatch = new CountDownLatch(clientCount);
-
- List>> allReceivedEvents = new ArrayList<>();
- List subscriptions = new ArrayList<>();
-
- // Connect clients
- for (int i = 0; i < clientCount; i++) {
- List> clientEvents = new ArrayList<>();
- allReceivedEvents.add(clientEvents);
-
- Flux> eventStream = webTestClient.get()
- .uri("/sse")
- .accept(MediaType.TEXT_EVENT_STREAM)
- .exchange()
- .expectStatus()
- .isOk()
- .returnResult(String.class)
- .getResponseBody()
- .map(data -> ServerSentEvent.builder().data(data).build());
-
- Disposable subscription = eventStream.doOnNext(event -> {
- clientEvents.add(event);
- if (event.event() != null && event.event().equals(SseServerTransport.ENDPOINT_EVENT_TYPE)) {
- connectLatch.countDown();
- }
- else if (event.event() != null && event.event().equals(SseServerTransport.MESSAGE_EVENT_TYPE)) {
- messageLatch.countDown();
- }
- }).subscribe();
-
- subscriptions.add(subscription);
- }
-
- // Wait for all clients to connect
- try {
- assertThat(connectLatch.await(5, TimeUnit.SECONDS)).isTrue();
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
-
- // Verify initial connections
- for (List> events : allReceivedEvents) {
- assertThat(events).hasSize(1);
- assertThat(events.get(0).data()).isEqualTo(messageEndpoint);
- }
-
- // Give clients time to fully establish their subscriptions
- logger.debug("Waiting for subscriptions to stabilize...");
- try {
- Thread.sleep(1000);
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- logger.debug("Sending broadcast message to {} clients", clientCount);
-
- // Send broadcast message
- JSONRPCRequest broadcastMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "broadcast", "broadcast-id",
- Map.of("message", "Hello all!"));
-
- // Send the message
- transport.sendMessage(broadcastMessage).block(Duration.ofSeconds(5));
-
- // Wait for all clients to receive the broadcast
- try {
- assertThat(messageLatch.await(5, TimeUnit.SECONDS)).isTrue();
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
-
- // Verify each client received both messages
- for (List> events : allReceivedEvents) {
- assertThat(events).hasSize(2);
- assertThat(events.get(0).data()).isEqualTo(messageEndpoint);
- assertThat(events.get(1).data()).contains("broadcast-id");
- }
-
- // Cleanup
- subscriptions.forEach(Disposable::dispose);
- }
-
- @Test
- void testGracefulShutdown() {
- // Connect a client
- Flux> eventStream = webTestClient.get()
- .uri("/sse")
- .accept(MediaType.TEXT_EVENT_STREAM)
- .exchange()
- .expectStatus()
- .isOk()
- .returnResult(String.class)
- .getResponseBody()
- .map(data -> ServerSentEvent.builder().data(data).build());
-
- List> receivedEvents = new ArrayList<>();
- eventStream.subscribe(receivedEvents::add);
-
- // Wait for connection
- StepVerifier.create(Mono.delay(Duration.ofMillis(500))).expectNextCount(1).verifyComplete();
-
- // Initiate shutdown
- transport.closeGracefully().block(Duration.ofSeconds(5));
-
- // Verify server rejects new connections with timeout
- webTestClient.get()
- .uri("/sse")
- .accept(MediaType.TEXT_EVENT_STREAM)
- .exchange()
- .expectStatus()
- .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
- .expectBody(String.class)
- .isEqualTo("Server is shutting down");
-
- // Verify server rejects new messages with timeout
- webTestClient.post()
- .uri(messageEndpoint)
- .contentType(MediaType.APPLICATION_JSON)
- .bodyValue("""
- {
- "jsonrpc": "2.0",
- "method": "test",
- "id": "1",
- "params": {}
- }
- """)
- .exchange()
- .expectStatus()
- .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
- .expectBody(String.class)
- .isEqualTo("Server is shutting down");
- }
-
- @Test
- void testInvalidMessageHandling() {
- // Test invalid JSON
- webTestClient.post()
- .uri(messageEndpoint)
- .contentType(MediaType.APPLICATION_JSON)
- .bodyValue("invalid json")
- .exchange()
- .expectStatus()
- .isBadRequest();
-
- // Test invalid message format
- webTestClient.post().uri(messageEndpoint).contentType(MediaType.APPLICATION_JSON).bodyValue("""
- {
- "invalid": "message"
- }
- """).exchange().expectStatus().isBadRequest();
- }
-
-}
diff --git a/mcp/README.md b/mcp/README.md
index 0fcffcb66..7a9ff8516 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -1,5 +1,5 @@
# Java MCP SDK
Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools.
+For comprehensive guides and API documentation, visit the [MCP Java SDK Reference Documentation](https://modelcontextprotocol.io/sdk/java/mcp-overview).
-Find more at [Java MCP SDK](https://docs.spring.io/spring-ai-mcp/reference/mcp.html)
\ No newline at end of file
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 3febfbfb4..927be2eb7 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -4,22 +4,66 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- org.springframework.experimental
+ io.modelcontextprotocol.sdk
mcp-parent
- 0.6.0
+ 0.8.0
mcp
jar
Java MCP SDK
Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools
- https://github.com/spring-projects-experimental/spring-ai-mcp
+ https://github.com/modelcontextprotocol/java-sdk
- https://github.com/spring-projects-experimental/spring-ai-mcp
- git://github.com/spring-projects-experimental/spring-ai-mcp.git
- git@github.com:spring-projects-experimental/spring-ai-mcp.git
+ https://github.com/modelcontextprotocol/java-sdk
+ git://github.com/modelcontextprotocol/java-sdk.git
+ git@github.com/modelcontextprotocol/java-sdk.git
+
+
+
+ biz.aQute.bnd
+ bnd-maven-plugin
+ ${bnd-maven-plugin.version}
+
+
+ bnd-process
+
+ bnd-process
+
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+ ${project.build.outputDirectory}/META-INF/MANIFEST.MF
+
+
+
+
+
+
@@ -114,12 +158,20 @@
test
+
+ net.javacrumbs.json-unit
+ json-unit-assertj
+ ${json-unit-assertj.version}
+ test
+
+
+
jakarta.servlet
jakarta.servlet-api
${jakarta.servlet.version}
- provided
+ provided
@@ -139,4 +191,4 @@
-
+
\ No newline at end of file
diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
new file mode 100644
index 000000000..9cbef0500
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
@@ -0,0 +1,797 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+package io.modelcontextprotocol.client;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import io.modelcontextprotocol.spec.ClientMcpTransport;
+import io.modelcontextprotocol.spec.McpClientSession;
+import io.modelcontextprotocol.spec.McpClientSession.NotificationHandler;
+import io.modelcontextprotocol.spec.McpClientSession.RequestHandler;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
+import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
+import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
+import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
+import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest;
+import io.modelcontextprotocol.spec.McpSchema.Root;
+import io.modelcontextprotocol.spec.McpTransport;
+import io.modelcontextprotocol.util.Assert;
+import io.modelcontextprotocol.util.Utils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+/**
+ * The Model Context Protocol (MCP) client implementation that provides asynchronous
+ * communication with MCP servers using Project Reactor's Mono and Flux types.
+ *
+ *
+ * This client implements the MCP specification, enabling AI models to interact with
+ * external tools and resources through a standardized interface. Key features include:
+ *
+ * Asynchronous communication using reactive programming patterns
+ * Tool discovery and invocation for server-provided functionality
+ * Resource access and management with URI-based addressing
+ * Prompt template handling for standardized AI interactions
+ * Real-time notifications for tools, resources, and prompts changes
+ * Structured logging with configurable severity levels
+ * Message sampling for AI model interactions
+ *
+ *
+ *
+ * The client follows a lifecycle:
+ *
+ * Initialization - Establishes connection and negotiates capabilities
+ * Normal Operation - Handles requests and notifications
+ * Graceful Shutdown - Ensures clean connection termination
+ *
+ *
+ *
+ * This implementation uses Project Reactor for non-blocking operations, making it
+ * suitable for high-throughput scenarios and reactive applications. All operations return
+ * Mono or Flux types that can be composed into reactive pipelines.
+ *
+ * @author Dariusz Jędrzejczyk
+ * @author Christian Tzolov
+ * @see McpClient
+ * @see McpSchema
+ * @see McpClientSession
+ */
+public class McpAsyncClient {
+
+ private static final Logger logger = LoggerFactory.getLogger(McpAsyncClient.class);
+
+ private static TypeReference VOID_TYPE_REFERENCE = new TypeReference<>() {
+ };
+
+ protected final Sinks.One initializedSink = Sinks.one();
+
+ private AtomicBoolean initialized = new AtomicBoolean(false);
+
+ /**
+ * The max timeout to await for the client-server connection to be initialized.
+ */
+ private final Duration initializationTimeout;
+
+ /**
+ * The MCP session implementation that manages bidirectional JSON-RPC communication
+ * between clients and servers.
+ */
+ private final McpClientSession mcpSession;
+
+ /**
+ * Client capabilities.
+ */
+ private final McpSchema.ClientCapabilities clientCapabilities;
+
+ /**
+ * Client implementation information.
+ */
+ private final McpSchema.Implementation clientInfo;
+
+ /**
+ * Server capabilities.
+ */
+ private McpSchema.ServerCapabilities serverCapabilities;
+
+ /**
+ * Server implementation information.
+ */
+ private McpSchema.Implementation serverInfo;
+
+ /**
+ * Roots define the boundaries of where servers can operate within the filesystem,
+ * allowing them to understand which directories and files they have access to.
+ * Servers can request the list of roots from supporting clients and receive
+ * notifications when that list changes.
+ */
+ private final ConcurrentHashMap roots;
+
+ /**
+ * MCP provides a standardized way for servers to request LLM sampling ("completions"
+ * or "generations") from language models via clients. This flow allows clients to
+ * maintain control over model access, selection, and permissions while enabling
+ * servers to leverage AI capabilities—with no server API keys necessary. Servers can
+ * request text or image-based interactions and optionally include context from MCP
+ * servers in their prompts.
+ */
+ private Function> samplingHandler;
+
+ /**
+ * Client transport implementation.
+ */
+ private final McpTransport transport;
+
+ /**
+ * Supported protocol versions.
+ */
+ private List protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
+
+ /**
+ * Create a new McpAsyncClient with the given transport and session request-response
+ * timeout.
+ * @param transport the transport to use.
+ * @param requestTimeout the session request-response timeout.
+ * @param initializationTimeout the max timeout to await for the client-server
+ * @param features the MCP Client supported features.
+ */
+ McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, Duration initializationTimeout,
+ McpClientFeatures.Async features) {
+
+ Assert.notNull(transport, "Transport must not be null");
+ Assert.notNull(requestTimeout, "Request timeout must not be null");
+ Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
+
+ this.clientInfo = features.clientInfo();
+ this.clientCapabilities = features.clientCapabilities();
+ this.transport = transport;
+ this.roots = new ConcurrentHashMap<>(features.roots());
+ this.initializationTimeout = initializationTimeout;
+
+ // Request Handlers
+ Map> requestHandlers = new HashMap<>();
+
+ // Roots List Request Handler
+ if (this.clientCapabilities.roots() != null) {
+ requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
+ }
+
+ // Sampling Handler
+ if (this.clientCapabilities.sampling() != null) {
+ if (features.samplingHandler() == null) {
+ throw new McpError("Sampling handler must not be null when client capabilities include sampling");
+ }
+ this.samplingHandler = features.samplingHandler();
+ requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
+ }
+
+ // Notification Handlers
+ Map notificationHandlers = new HashMap<>();
+
+ // Tools Change Notification
+ List, Mono>> toolsChangeConsumersFinal = new ArrayList<>();
+ toolsChangeConsumersFinal
+ .add((notification) -> Mono.fromRunnable(() -> logger.debug("Tools changed: {}", notification)));
+
+ if (!Utils.isEmpty(features.toolsChangeConsumers())) {
+ toolsChangeConsumersFinal.addAll(features.toolsChangeConsumers());
+ }
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
+ asyncToolsChangeNotificationHandler(toolsChangeConsumersFinal));
+
+ // Resources Change Notification
+ List, Mono>> resourcesChangeConsumersFinal = new ArrayList<>();
+ resourcesChangeConsumersFinal
+ .add((notification) -> Mono.fromRunnable(() -> logger.debug("Resources changed: {}", notification)));
+
+ if (!Utils.isEmpty(features.resourcesChangeConsumers())) {
+ resourcesChangeConsumersFinal.addAll(features.resourcesChangeConsumers());
+ }
+
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED,
+ asyncResourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
+
+ // Prompts Change Notification
+ List, Mono>> promptsChangeConsumersFinal = new ArrayList<>();
+ promptsChangeConsumersFinal
+ .add((notification) -> Mono.fromRunnable(() -> logger.debug("Prompts changed: {}", notification)));
+ if (!Utils.isEmpty(features.promptsChangeConsumers())) {
+ promptsChangeConsumersFinal.addAll(features.promptsChangeConsumers());
+ }
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED,
+ asyncPromptsChangeNotificationHandler(promptsChangeConsumersFinal));
+
+ // Utility Logging Notification
+ List>> loggingConsumersFinal = new ArrayList<>();
+ loggingConsumersFinal.add((notification) -> Mono.fromRunnable(() -> logger.debug("Logging: {}", notification)));
+ if (!Utils.isEmpty(features.loggingConsumers())) {
+ loggingConsumersFinal.addAll(features.loggingConsumers());
+ }
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
+ asyncLoggingNotificationHandler(loggingConsumersFinal));
+
+ this.mcpSession = new McpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers);
+
+ }
+
+ /**
+ * Get the server capabilities that define the supported features and functionality.
+ * @return The server capabilities
+ */
+ public McpSchema.ServerCapabilities getServerCapabilities() {
+ return this.serverCapabilities;
+ }
+
+ /**
+ * Get the server implementation information.
+ * @return The server implementation details
+ */
+ public McpSchema.Implementation getServerInfo() {
+ return this.serverInfo;
+ }
+
+ /**
+ * Check if the client-server connection is initialized.
+ * @return true if the client-server connection is initialized
+ */
+ public boolean isInitialized() {
+ return this.initialized.get();
+ }
+
+ /**
+ * Get the client capabilities that define the supported features and functionality.
+ * @return The client capabilities
+ */
+ public ClientCapabilities getClientCapabilities() {
+ return this.clientCapabilities;
+ }
+
+ /**
+ * Get the client implementation information.
+ * @return The client implementation details
+ */
+ public McpSchema.Implementation getClientInfo() {
+ return this.clientInfo;
+ }
+
+ /**
+ * Closes the client connection immediately.
+ */
+ public void close() {
+ this.mcpSession.close();
+ }
+
+ /**
+ * Gracefully closes the client connection.
+ * @return A Mono that completes when the connection is closed
+ */
+ public Mono closeGracefully() {
+ return this.mcpSession.closeGracefully();
+ }
+
+ // --------------------------
+ // Initialization
+ // --------------------------
+ /**
+ * The initialization phase MUST be the first interaction between client and server.
+ * During this phase, the client and server:
+ *
+ * Establish protocol version compatibility
+ * Exchange and negotiate capabilities
+ * Share implementation details
+ *
+ *
+ * The client MUST initiate this phase by sending an initialize request containing:
+ * The protocol version the client supports, client's capabilities and clients
+ * implementation information.
+ *
+ * The server MUST respond with its own capabilities and information.
+ *
+ * After successful initialization, the client MUST send an initialized notification
+ * to indicate it is ready to begin normal operations.
+ * @return the initialize result.
+ * @see MCP
+ * Initialization Spec
+ */
+ public Mono initialize() {
+
+ String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
+
+ McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
+ latestVersion,
+ this.clientCapabilities,
+ this.clientInfo); // @formatter:on
+
+ Mono result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
+ initializeRequest, new TypeReference() {
+ });
+
+ return result.flatMap(initializeResult -> {
+
+ this.serverCapabilities = initializeResult.capabilities();
+ this.serverInfo = initializeResult.serverInfo();
+
+ logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
+ initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
+ initializeResult.instructions());
+
+ if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
+ return Mono.error(new McpError(
+ "Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
+ }
+
+ return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null).doOnSuccess(v -> {
+ this.initialized.set(true);
+ this.initializedSink.tryEmitValue(initializeResult);
+ }).thenReturn(initializeResult);
+ });
+ }
+
+ /**
+ * Utility method to handle the common pattern of checking initialization before
+ * executing an operation.
+ * @param The type of the result Mono
+ * @param actionName The action to perform if the client is initialized
+ * @param operation The operation to execute if the client is initialized
+ * @return A Mono that completes with the result of the operation
+ */
+ private Mono withInitializationCheck(String actionName,
+ Function> operation) {
+ return this.initializedSink.asMono()
+ .timeout(this.initializationTimeout)
+ .onErrorResume(TimeoutException.class,
+ ex -> Mono.error(new McpError("Client must be initialized before " + actionName)))
+ .flatMap(operation);
+ }
+
+ // --------------------------
+ // Basic Utilites
+ // --------------------------
+
+ /**
+ * Sends a ping request to the server.
+ * @return A Mono that completes with the server's ping response
+ */
+ public Mono ping() {
+ return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession
+ .sendRequest(McpSchema.METHOD_PING, null, new TypeReference() {
+ }));
+ }
+
+ // --------------------------
+ // Roots
+ // --------------------------
+ /**
+ * Adds a new root to the client's root list.
+ * @param root The root to add.
+ * @return A Mono that completes when the root is added and notifications are sent.
+ */
+ public Mono addRoot(Root root) {
+
+ if (root == null) {
+ return Mono.error(new McpError("Root must not be null"));
+ }
+
+ if (this.clientCapabilities.roots() == null) {
+ return Mono.error(new McpError("Client must be configured with roots capabilities"));
+ }
+
+ if (this.roots.containsKey(root.uri())) {
+ return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
+ }
+
+ this.roots.put(root.uri(), root);
+
+ logger.debug("Added root: {}", root);
+
+ if (this.clientCapabilities.roots().listChanged()) {
+ if (this.isInitialized()) {
+ return this.rootsListChangedNotification();
+ }
+ else {
+ logger.warn("Client is not initialized, ignore sending a roots list changed notification");
+ }
+ }
+ return Mono.empty();
+ }
+
+ /**
+ * Removes a root from the client's root list.
+ * @param rootUri The URI of the root to remove.
+ * @return A Mono that completes when the root is removed and notifications are sent.
+ */
+ public Mono removeRoot(String rootUri) {
+
+ if (rootUri == null) {
+ return Mono.error(new McpError("Root uri must not be null"));
+ }
+
+ if (this.clientCapabilities.roots() == null) {
+ return Mono.error(new McpError("Client must be configured with roots capabilities"));
+ }
+
+ Root removed = this.roots.remove(rootUri);
+
+ if (removed != null) {
+ logger.debug("Removed Root: {}", rootUri);
+ if (this.clientCapabilities.roots().listChanged()) {
+ if (this.isInitialized()) {
+ return this.rootsListChangedNotification();
+ }
+ else {
+ logger.warn("Client is not initialized, ignore sending a roots list changed notification");
+ }
+
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
+ }
+
+ /**
+ * Manually sends a roots/list_changed notification. The addRoot and removeRoot
+ * methods automatically send the roots/list_changed notification if the client is in
+ * an initialized state.
+ * @return A Mono that completes when the notification is sent.
+ */
+ public Mono rootsListChangedNotification() {
+ return this.withInitializationCheck("sending roots list changed notification",
+ initResult -> this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED));
+ }
+
+ private RequestHandler rootsListRequestHandler() {
+ return params -> {
+ @SuppressWarnings("unused")
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ List roots = this.roots.values().stream().toList();
+
+ return Mono.just(new McpSchema.ListRootsResult(roots));
+ };
+ }
+
+ // --------------------------
+ // Sampling
+ // --------------------------
+ private RequestHandler samplingCreateMessageHandler() {
+ return params -> {
+ McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ return this.samplingHandler.apply(request);
+ };
+ }
+
+ // --------------------------
+ // Tools
+ // --------------------------
+ private static final TypeReference CALL_TOOL_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ private static final TypeReference LIST_TOOLS_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ /**
+ * Calls a tool provided by the server. Tools enable servers to expose executable
+ * functionality that can interact with external systems, perform computations, and
+ * take actions in the real world.
+ * @param callToolRequest The request containing the tool name and input parameters.
+ * @return A Mono that emits the result of the tool call, including the output and any
+ * errors.
+ * @see McpSchema.CallToolRequest
+ * @see McpSchema.CallToolResult
+ * @see #listTools()
+ */
+ public Mono callTool(McpSchema.CallToolRequest callToolRequest) {
+ return this.withInitializationCheck("calling tools", initializedResult -> {
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server does not provide tools capability"));
+ }
+ return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
+ });
+ }
+
+ /**
+ * Retrieves the list of all tools provided by the server.
+ * @return A Mono that emits the list of tools result.
+ */
+ public Mono listTools() {
+ return this.listTools(null);
+ }
+
+ /**
+ * Retrieves a paginated list of tools provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return A Mono that emits the list of tools result
+ */
+ public Mono listTools(String cursor) {
+ return this.withInitializationCheck("listing tools", initializedResult -> {
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server does not provide tools capability"));
+ }
+ return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
+ LIST_TOOLS_RESULT_TYPE_REF);
+ });
+ }
+
+ private NotificationHandler asyncToolsChangeNotificationHandler(
+ List, Mono>> toolsChangeConsumers) {
+ // TODO: params are not used yet
+ return params -> this.listTools()
+ .flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
+ .flatMap(consumer -> consumer.apply(listToolsResult.tools()))
+ .onErrorResume(error -> {
+ logger.error("Error handling tools list change notification", error);
+ return Mono.empty();
+ })
+ .then());
+ }
+
+ // --------------------------
+ // Resources
+ // --------------------------
+
+ private static final TypeReference LIST_RESOURCES_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ private static final TypeReference READ_RESOURCE_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ private static final TypeReference LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ /**
+ * Retrieves the list of all resources provided by the server. Resources represent any
+ * kind of UTF-8 encoded data that an MCP server makes available to clients, such as
+ * database records, API responses, log files, and more.
+ * @return A Mono that completes with the list of resources result.
+ * @see McpSchema.ListResourcesResult
+ * @see #readResource(McpSchema.Resource)
+ */
+ public Mono listResources() {
+ return this.listResources(null);
+ }
+
+ /**
+ * Retrieves a paginated list of resources provided by the server. Resources represent
+ * any kind of UTF-8 encoded data that an MCP server makes available to clients, such
+ * as database records, API responses, log files, and more.
+ * @param cursor Optional pagination cursor from a previous list request.
+ * @return A Mono that completes with the list of resources result.
+ * @see McpSchema.ListResourcesResult
+ * @see #readResource(McpSchema.Resource)
+ */
+ public Mono listResources(String cursor) {
+ return this.withInitializationCheck("listing resources", initializedResult -> {
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server does not provide the resources capability"));
+ }
+ return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
+ LIST_RESOURCES_RESULT_TYPE_REF);
+ });
+ }
+
+ /**
+ * Reads the content of a specific resource identified by the provided Resource
+ * object. This method fetches the actual data that the resource represents.
+ * @param resource The resource to read, containing the URI that identifies the
+ * resource.
+ * @return A Mono that completes with the resource content.
+ * @see McpSchema.Resource
+ * @see McpSchema.ReadResourceResult
+ */
+ public Mono readResource(McpSchema.Resource resource) {
+ return this.readResource(new McpSchema.ReadResourceRequest(resource.uri()));
+ }
+
+ /**
+ * Reads the content of a specific resource identified by the provided request. This
+ * method fetches the actual data that the resource represents.
+ * @param readResourceRequest The request containing the URI of the resource to read
+ * @return A Mono that completes with the resource content.
+ * @see McpSchema.ReadResourceRequest
+ * @see McpSchema.ReadResourceResult
+ */
+ public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) {
+ return this.withInitializationCheck("reading resources", initializedResult -> {
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server does not provide the resources capability"));
+ }
+ return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
+ READ_RESOURCE_RESULT_TYPE_REF);
+ });
+ }
+
+ /**
+ * Retrieves the list of all resource templates provided by the server. Resource
+ * templates allow servers to expose parameterized resources using URI templates,
+ * enabling dynamic resource access based on variable parameters.
+ * @return A Mono that completes with the list of resource templates result.
+ * @see McpSchema.ListResourceTemplatesResult
+ */
+ public Mono listResourceTemplates() {
+ return this.listResourceTemplates(null);
+ }
+
+ /**
+ * Retrieves a paginated list of resource templates provided by the server. Resource
+ * templates allow servers to expose parameterized resources using URI templates,
+ * enabling dynamic resource access based on variable parameters.
+ * @param cursor Optional pagination cursor from a previous list request.
+ * @return A Mono that completes with the list of resource templates result.
+ * @see McpSchema.ListResourceTemplatesResult
+ */
+ public Mono listResourceTemplates(String cursor) {
+ return this.withInitializationCheck("listing resource templates", initializedResult -> {
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server does not provide the resources capability"));
+ }
+ return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
+ new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
+ });
+ }
+
+ /**
+ * Subscribes to changes in a specific resource. When the resource changes on the
+ * server, the client will receive notifications through the resources change
+ * notification handler.
+ * @param subscribeRequest The subscribe request containing the URI of the resource.
+ * @return A Mono that completes when the subscription is complete.
+ * @see McpSchema.SubscribeRequest
+ * @see #unsubscribeResource(McpSchema.UnsubscribeRequest)
+ */
+ public Mono subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
+ return this.withInitializationCheck("subscribing to resources", initializedResult -> this.mcpSession
+ .sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE));
+ }
+
+ /**
+ * Cancels an existing subscription to a resource. After unsubscribing, the client
+ * will no longer receive notifications when the resource changes.
+ * @param unsubscribeRequest The unsubscribe request containing the URI of the
+ * resource.
+ * @return A Mono that completes when the unsubscription is complete.
+ * @see McpSchema.UnsubscribeRequest
+ * @see #subscribeResource(McpSchema.SubscribeRequest)
+ */
+ public Mono unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
+ return this.withInitializationCheck("unsubscribing from resources", initializedResult -> this.mcpSession
+ .sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, VOID_TYPE_REFERENCE));
+ }
+
+ private NotificationHandler asyncResourcesChangeNotificationHandler(
+ List, Mono>> resourcesChangeConsumers) {
+ return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers)
+ .flatMap(consumer -> consumer.apply(listResourcesResult.resources()))
+ .onErrorResume(error -> {
+ logger.error("Error handling resources list change notification", error);
+ return Mono.empty();
+ })
+ .then());
+ }
+
+ // --------------------------
+ // Prompts
+ // --------------------------
+ private static final TypeReference LIST_PROMPTS_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ private static final TypeReference GET_PROMPT_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ /**
+ * Retrieves the list of all prompts provided by the server.
+ * @return A Mono that completes with the list of prompts result.
+ * @see McpSchema.ListPromptsResult
+ * @see #getPrompt(GetPromptRequest)
+ */
+ public Mono listPrompts() {
+ return this.listPrompts(null);
+ }
+
+ /**
+ * Retrieves a paginated list of prompts provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return A Mono that completes with the list of prompts result.
+ * @see McpSchema.ListPromptsResult
+ * @see #getPrompt(GetPromptRequest)
+ */
+ public Mono listPrompts(String cursor) {
+ return this.withInitializationCheck("listing prompts", initializedResult -> this.mcpSession
+ .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF));
+ }
+
+ /**
+ * Retrieves a specific prompt by its ID. This provides the complete prompt template
+ * including all parameters and instructions for generating AI content.
+ * @param getPromptRequest The request containing the ID of the prompt to retrieve.
+ * @return A Mono that completes with the prompt result.
+ * @see McpSchema.GetPromptRequest
+ * @see McpSchema.GetPromptResult
+ * @see #listPrompts()
+ */
+ public Mono getPrompt(GetPromptRequest getPromptRequest) {
+ return this.withInitializationCheck("getting prompts", initializedResult -> this.mcpSession
+ .sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF));
+ }
+
+ private NotificationHandler asyncPromptsChangeNotificationHandler(
+ List, Mono>> promptsChangeConsumers) {
+ return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)
+ .flatMap(consumer -> consumer.apply(listPromptsResult.prompts()))
+ .onErrorResume(error -> {
+ logger.error("Error handling prompts list change notification", error);
+ return Mono.empty();
+ })
+ .then());
+ }
+
+ // --------------------------
+ // Logging
+ // --------------------------
+ private NotificationHandler asyncLoggingNotificationHandler(
+ List>> loggingConsumers) {
+
+ return params -> {
+ McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ return Flux.fromIterable(loggingConsumers)
+ .flatMap(consumer -> consumer.apply(loggingMessageNotification))
+ .then();
+ };
+ }
+
+ /**
+ * Sets the minimum logging level for messages received from the server. The client
+ * will only receive log messages at or above the specified severity level.
+ * @param loggingLevel The minimum logging level to receive.
+ * @return A Mono that completes when the logging level is set.
+ * @see McpSchema.LoggingLevel
+ */
+ public Mono setLoggingLevel(LoggingLevel loggingLevel) {
+ if (loggingLevel == null) {
+ return Mono.error(new McpError("Logging level must not be null"));
+ }
+
+ return this.withInitializationCheck("setting logging level", initializedResult -> {
+ String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference() {
+ });
+ Map params = Map.of("level", levelName);
+ return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params);
+ });
+ }
+
+ /**
+ * This method is package-private and used for test only. Should not be called by user
+ * code.
+ * @param protocolVersions the Client supported protocol versions.
+ */
+ void setProtocolVersions(List protocolVersions) {
+ this.protocolVersions = protocolVersions;
+ }
+
+}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java
similarity index 67%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java
index e11a9a713..9c5f7b015 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java
@@ -1,20 +1,8 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client;
+package io.modelcontextprotocol.client;
import java.time.Duration;
import java.util.ArrayList;
@@ -24,18 +12,18 @@
import java.util.function.Consumer;
import java.util.function.Function;
+import io.modelcontextprotocol.spec.ClientMcpTransport;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpTransport;
+import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
+import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
+import io.modelcontextprotocol.spec.McpSchema.Implementation;
+import io.modelcontextprotocol.spec.McpSchema.Root;
+import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest;
-import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult;
-import org.springframework.ai.mcp.spec.McpSchema.Implementation;
-import org.springframework.ai.mcp.spec.McpSchema.Root;
-import org.springframework.ai.mcp.spec.McpTransport;
-import org.springframework.ai.mcp.util.Assert;
-
/**
* Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that
* enables AI models to interact with external tools and resources through a standardized
@@ -126,11 +114,31 @@ public interface McpClient {
* and {@code SseClientTransport} for SSE-based communication.
* @return A new builder instance for configuring the client
* @throws IllegalArgumentException if transport is null
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #sync(McpClientTransport)}
*/
+ @Deprecated
static SyncSpec sync(ClientMcpTransport transport) {
return new SyncSpec(transport);
}
+ /**
+ * Start building a synchronous MCP client with the specified transport layer. The
+ * synchronous MCP client provides blocking operations. Synchronous clients wait for
+ * each operation to complete before returning, making them simpler to use but
+ * potentially less performant for concurrent operations. The transport layer handles
+ * the low-level communication between client and server using protocols like stdio or
+ * Server-Sent Events (SSE).
+ * @param transport The transport layer implementation for MCP communication. Common
+ * implementations include {@code StdioClientTransport} for stdio-based communication
+ * and {@code SseClientTransport} for SSE-based communication.
+ * @return A new builder instance for configuring the client
+ * @throws IllegalArgumentException if transport is null
+ */
+ static SyncSpec sync(McpClientTransport transport) {
+ return new SyncSpec(transport);
+ }
+
/**
* Start building an asynchronous MCP client with the specified transport layer. The
* asynchronous MCP client provides non-blocking operations. Asynchronous clients
@@ -143,265 +151,29 @@ static SyncSpec sync(ClientMcpTransport transport) {
* and {@code SseClientTransport} for SSE-based communication.
* @return A new builder instance for configuring the client
* @throws IllegalArgumentException if transport is null
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #async(McpClientTransport)}
*/
+ @Deprecated
static AsyncSpec async(ClientMcpTransport transport) {
return new AsyncSpec(transport);
}
/**
- * Start building an MCP client with the specified transport layer. The transport
- * layer handles the low-level communication between client and server using protocols
- * like stdio or Server-Sent Events (SSE).
+ * Start building an asynchronous MCP client with the specified transport layer. The
+ * asynchronous MCP client provides non-blocking operations. Asynchronous clients
+ * return reactive primitives (Mono/Flux) immediately, allowing for concurrent
+ * operations and reactive programming patterns. The transport layer handles the
+ * low-level communication between client and server using protocols like stdio or
+ * Server-Sent Events (SSE).
* @param transport The transport layer implementation for MCP communication. Common
* implementations include {@code StdioClientTransport} for stdio-based communication
* and {@code SseClientTransport} for SSE-based communication.
* @return A new builder instance for configuring the client
* @throws IllegalArgumentException if transport is null
- * @deprecated Use {@link #sync(ClientMcpTransport)} or
- * {@link #async(ClientMcpTransport)} specification builder to configure the client
- * and build an instance.
*/
- @Deprecated
- public static Builder using(ClientMcpTransport transport) {
- return new Builder(transport);
- }
-
- /**
- * Builder class for creating and configuring MCP clients. This class follows the
- * builder pattern to provide a fluent API for setting up clients with custom
- * configurations.
- *
- *
- * The builder supports configuration of:
- *
- * Transport layer for client-server communication
- * Request timeouts for operation boundaries
- * Client capabilities for feature negotiation
- * Client implementation details for version tracking
- * Root URIs for resource access
- * Change notification handlers for tools, resources, and prompts
- * Custom message sampling logic
- *
- *
- * @deprecated Use {@link #sync(ClientMcpTransport)} or
- * {@link #async(ClientMcpTransport)} specification builder to instantiate an
- * instance.
- */
- @Deprecated
- public static class Builder {
-
- private final ClientMcpTransport transport;
-
- private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
-
- private ClientCapabilities capabilities;
-
- private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
-
- private Map roots = new HashMap<>();
-
- private List>> toolsChangeConsumers = new ArrayList<>();
-
- private List>> resourcesChangeConsumers = new ArrayList<>();
-
- private List>> promptsChangeConsumers = new ArrayList<>();
-
- private List> loggingConsumers = new ArrayList<>();
-
- private Function samplingHandler;
-
- private Builder(ClientMcpTransport transport) {
- Assert.notNull(transport, "Transport must not be null");
- this.transport = transport;
- }
-
- /**
- * Sets the duration to wait for server responses before timing out requests. This
- * timeout applies to all requests made through the client, including tool calls,
- * resource access, and prompt operations.
- * @param requestTimeout The duration to wait before timing out requests. Must not
- * be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if requestTimeout is null
- */
- public Builder requestTimeout(Duration requestTimeout) {
- Assert.notNull(requestTimeout, "Request timeout must not be null");
- this.requestTimeout = requestTimeout;
- return this;
- }
-
- /**
- * Sets the client capabilities that will be advertised to the server during
- * connection initialization. Capabilities define what features the client
- * supports, such as tool execution, resource access, and prompt handling.
- * @param capabilities The client capabilities configuration. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if capabilities is null
- */
- public Builder capabilities(ClientCapabilities capabilities) {
- Assert.notNull(capabilities, "Capabilities must not be null");
- this.capabilities = capabilities;
- return this;
- }
-
- /**
- * Sets the client implementation information that will be shared with the server
- * during connection initialization. This helps with version compatibility and
- * debugging.
- * @param clientInfo The client implementation details including name and version.
- * Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if clientInfo is null
- */
- public Builder clientInfo(Implementation clientInfo) {
- Assert.notNull(clientInfo, "Client info must not be null");
- this.clientInfo = clientInfo;
- return this;
- }
-
- /**
- * Sets the root URIs that this client can access. Roots define the base URIs for
- * resources that the client can request from the server. For example, a root
- * might be "file://workspace" for accessing workspace files.
- * @param roots A list of root definitions. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if roots is null
- */
- public Builder roots(List roots) {
- Assert.notNull(roots, "Roots must not be null");
- for (Root root : roots) {
- this.roots.put(root.uri(), root);
- }
- return this;
- }
-
- /**
- * Sets the root URIs that this client can access, using a varargs parameter for
- * convenience. This is an alternative to {@link #roots(List)}.
- * @param roots An array of root definitions. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if roots is null
- * @see #roots(List)
- */
- public Builder roots(Root... roots) {
- Assert.notNull(roots, "Roots must not be null");
- for (Root root : roots) {
- this.roots.put(root.uri(), root);
- }
- return this;
- }
-
- /**
- * Sets a custom sampling handler for processing message creation requests. The
- * sampling handler can modify or validate messages before they are sent to the
- * server, enabling custom processing logic.
- * @param samplingHandler A function that processes message requests and returns
- * results. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if samplingHandler is null
- */
- public Builder sampling(Function samplingHandler) {
- Assert.notNull(samplingHandler, "Sampling handler must not be null");
- this.samplingHandler = samplingHandler;
- return this;
- }
-
- /**
- * Adds a consumer to be notified when the available tools change. This allows the
- * client to react to changes in the server's tool capabilities, such as tools
- * being added or removed.
- * @param toolsChangeConsumer A consumer that receives the updated list of
- * available tools. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if toolsChangeConsumer is null
- */
- public Builder toolsChangeConsumer(Consumer> toolsChangeConsumer) {
- Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
- this.toolsChangeConsumers.add(toolsChangeConsumer);
- return this;
- }
-
- /**
- * Adds a consumer to be notified when the available resources change. This allows
- * the client to react to changes in the server's resource availability, such as
- * files being added or removed.
- * @param resourcesChangeConsumer A consumer that receives the updated list of
- * available resources. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if resourcesChangeConsumer is null
- */
- public Builder resourcesChangeConsumer(Consumer> resourcesChangeConsumer) {
- Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
- this.resourcesChangeConsumers.add(resourcesChangeConsumer);
- return this;
- }
-
- /**
- * Adds a consumer to be notified when the available prompts change. This allows
- * the client to react to changes in the server's prompt templates, such as new
- * templates being added or existing ones being modified.
- * @param promptsChangeConsumer A consumer that receives the updated list of
- * available prompts. Must not be null.
- * @return This builder instance for method chaining
- * @throws IllegalArgumentException if promptsChangeConsumer is null
- */
- public Builder promptsChangeConsumer(Consumer> promptsChangeConsumer) {
- Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
- this.promptsChangeConsumers.add(promptsChangeConsumer);
- return this;
- }
-
- /**
- * Adds a consumer to be notified when logging messages are received from the
- * server. This allows the client to react to log messages, such as warnings or
- * errors, that are sent by the server.
- * @param loggingConsumer A consumer that receives logging messages. Must not be
- * null.
- * @return This builder instance for method chaining
- */
- public Builder loggingConsumer(Consumer loggingConsumer) {
- Assert.notNull(loggingConsumer, "Logging consumer must not be null");
- this.loggingConsumers.add(loggingConsumer);
- return this;
- }
-
- /**
- * Adds multiple consumers to be notified when logging messages are received from
- * the server. This allows the client to react to log messages, such as warnings
- * or errors, that are sent by the server.
- * @param loggingConsumers A list of consumers that receive logging messages. Must
- * not be null.
- * @return This builder instance for method chaining
- */
- public Builder loggingConsumers(List> loggingConsumers) {
- Assert.notNull(loggingConsumers, "Logging consumers must not be null");
- this.loggingConsumers.addAll(loggingConsumers);
- return this;
- }
-
- /**
- * Builds a synchronous MCP client that provides blocking operations. Synchronous
- * clients wait for each operation to complete before returning, making them
- * simpler to use but potentially less performant for concurrent operations.
- * @return A new instance of {@link McpSyncClient} configured with this builder's
- * settings
- */
- public McpSyncClient sync() {
- return new McpSyncClient(async());
- }
-
- /**
- * Builds an asynchronous MCP client that provides non-blocking operations.
- * Asynchronous clients return CompletableFuture objects immediately, allowing for
- * concurrent operations and reactive programming patterns.
- * @return A new instance of {@link McpAsyncClient} configured with this builder's
- * settings
- */
- public McpAsyncClient async() {
- return new McpAsyncClient(transport, requestTimeout, clientInfo, capabilities, roots, toolsChangeConsumers,
- resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers, samplingHandler);
- }
-
+ static AsyncSpec async(McpClientTransport transport) {
+ return new AsyncSpec(transport);
}
/**
@@ -426,9 +198,11 @@ class SyncSpec {
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
+ private Duration initializationTimeout = Duration.ofSeconds(20);
+
private ClientCapabilities capabilities;
- private Implementation clientInfo = new Implementation("Spring AI MCP Client", "1.0.0");
+ private Implementation clientInfo = new Implementation("Java SDK MCP Client", "1.0.0");
private final Map roots = new HashMap<>();
@@ -462,6 +236,18 @@ public SyncSpec requestTimeout(Duration requestTimeout) {
return this;
}
+ /**
+ * @param initializationTimeout The duration to wait for the initializaiton
+ * lifecycle step to complete.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if initializationTimeout is null
+ */
+ public SyncSpec initializationTimeout(Duration initializationTimeout) {
+ Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
+ this.initializationTimeout = initializationTimeout;
+ return this;
+ }
+
/**
* Sets the client capabilities that will be advertised to the server during
* connection initialization. Capabilities define what features the client
@@ -623,7 +409,8 @@ public McpSyncClient build() {
McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);
- return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, asyncFeatures));
+ return new McpSyncClient(
+ new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, asyncFeatures));
}
}
@@ -650,6 +437,8 @@ class AsyncSpec {
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
+ private Duration initializationTimeout = Duration.ofSeconds(20);
+
private ClientCapabilities capabilities;
private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
@@ -686,6 +475,18 @@ public AsyncSpec requestTimeout(Duration requestTimeout) {
return this;
}
+ /**
+ * @param initializationTimeout The duration to wait for the initializaiton
+ * lifecycle step to complete.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if initializationTimeout is null
+ */
+ public AsyncSpec initializationTimeout(Duration initializationTimeout) {
+ Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
+ this.initializationTimeout = initializationTimeout;
+ return this;
+ }
+
/**
* Sets the client capabilities that will be advertised to the server during
* connection initialization. Capabilities define what features the client
@@ -843,7 +644,7 @@ public AsyncSpec loggingConsumers(
* @return a new instance of {@link McpAsyncClient}.
*/
public McpAsyncClient build() {
- return new McpAsyncClient(this.transport, this.requestTimeout,
+ return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout,
new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots,
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers,
this.loggingConsumers, this.samplingHandler));
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java
similarity index 97%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java
index 05be98de4..284b93f88 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java
@@ -1,4 +1,8 @@
-package org.springframework.ai.mcp.client;
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client;
import java.util.ArrayList;
import java.util.HashMap;
@@ -8,13 +12,12 @@
import java.util.function.Consumer;
import java.util.function.Function;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.util.Assert;
+import io.modelcontextprotocol.util.Utils;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.util.Assert;
-import org.springframework.ai.mcp.util.Utils;
-
/**
* Representation of features and capabilities for Model Context Protocol (MCP) clients.
* This class provides two record types for managing client features:
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
similarity index 85%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
index 1d008ab94..ec0a0dfdb 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
@@ -1,34 +1,21 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client;
+package io.modelcontextprotocol.client;
import java.time.Duration;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
+import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
+import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
+import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
-import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
-import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
-import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
-import org.springframework.ai.mcp.util.Assert;
-
/**
* A synchronous client implementation for the Model Context Protocol (MCP) that wraps an
* {@link McpAsyncClient} to provide blocking operations.
@@ -79,7 +66,8 @@ public class McpSyncClient implements AutoCloseable {
* Create a new McpSyncClient with the given delegate.
* @param delegate the asynchronous kernel on top of which this synchronous client
* provides a blocking API.
- * @deprecated Use {@link McpClient#sync(ClientMcpTransport)} to obtain an instance.
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link McpClient#sync(McpClientTransport)} to obtain an instance.
*/
@Deprecated
// TODO make the constructor package private post-deprecation
@@ -297,14 +285,6 @@ public McpSchema.ListResourceTemplatesResult listResourceTemplates() {
return this.delegate.listResourceTemplates().block();
}
- /**
- * List Changed Notification. When the list of available resources changes, servers
- * that declared the listChanged capability SHOULD send a notification:
- */
- public void sendResourcesListChanged() {
- this.delegate.sendResourcesListChanged().block();
- }
-
/**
* Subscriptions. The protocol supports optional subscriptions to resource changes.
* Clients can subscribe to specific resources and receive notifications when they
@@ -342,15 +322,6 @@ public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) {
return this.delegate.getPrompt(getPromptRequest).block();
}
- /**
- * (Server) An optional notification from the server to the client, informing it that
- * the list of prompts it offers has changed. This may be issued by servers without
- * any previous subscription from the client.
- */
- public void promptListChangedNotification() {
- this.delegate.promptListChangedNotification().block();
- }
-
/**
* Client can set the minimum logging level it wants to receive from the server.
* @param loggingLevel the min logging level
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/FlowSseClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/transport/FlowSseClient.java
similarity index 91%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/transport/FlowSseClient.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/transport/FlowSseClient.java
index 4b6191d9f..7fc679937 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/FlowSseClient.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/transport/FlowSseClient.java
@@ -1,19 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-* https://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
*/
-package org.springframework.ai.mcp.client.transport;
+package io.modelcontextprotocol.client.transport;
import java.net.URI;
import java.net.http.HttpClient;
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransport.java b/mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java
similarity index 86%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransport.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java
index 68f103286..ca1b0e87a 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java
@@ -1,33 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client.transport;
-
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import org.springframework.ai.mcp.client.transport.FlowSseClient.SseEvent;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpError;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage;
-import org.springframework.ai.mcp.util.Assert;
-
-import reactor.core.publisher.Mono;
+package io.modelcontextprotocol.client.transport;
import java.io.IOException;
import java.net.URI;
@@ -41,9 +15,21 @@
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.client.transport.FlowSseClient.SseEvent;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
+import io.modelcontextprotocol.util.Assert;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+
/**
* Server-Sent Events (SSE) implementation of the
- * {@link org.springframework.ai.mcp.spec.McpTransport} that follows the MCP HTTP with SSE
+ * {@link io.modelcontextprotocol.spec.McpTransport} that follows the MCP HTTP with SSE
* transport specification, using Java's HttpClient.
*
*
@@ -65,10 +51,10 @@
*
*
* @author Christian Tzolov
- * @see org.springframework.ai.mcp.spec.McpTransport
- * @see org.springframework.ai.mcp.spec.ClientMcpTransport
+ * @see io.modelcontextprotocol.spec.McpTransport
+ * @see io.modelcontextprotocol.spec.McpClientTransport
*/
-public class HttpClientSseClientTransport implements ClientMcpTransport {
+public class HttpClientSseClientTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(HttpClientSseClientTransport.class);
@@ -100,7 +86,7 @@ public class HttpClientSseClientTransport implements ClientMcpTransport {
private volatile boolean isClosing = false;
/** Latch for coordinating endpoint discovery */
- private CountDownLatch closeLatch = new CountDownLatch(1);
+ private final CountDownLatch closeLatch = new CountDownLatch(1);
/** Holds the discovered message endpoint URL */
private final AtomicReference messageEndpoint = new AtomicReference<>();
@@ -231,7 +217,8 @@ public Mono sendMessage(JSONRPCMessage message) {
return Mono.fromFuture(
httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).thenAccept(response -> {
- if (response.statusCode() != 200) {
+ if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 202
+ && response.statusCode() != 206) {
logger.error("Error sending message: {}", response.statusCode());
}
}));
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/ServerParameters.java b/mcp/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java
similarity index 83%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/transport/ServerParameters.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java
index a5c685812..25a02279f 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/ServerParameters.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java
@@ -1,20 +1,8 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client.transport;
+package io.modelcontextprotocol.client.transport;
import java.util.ArrayList;
import java.util.Arrays;
@@ -25,8 +13,7 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
-
-import org.springframework.ai.mcp.util.Assert;
+import io.modelcontextprotocol.util.Assert;
/**
* Server parameters for stdio client.
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java b/mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
similarity index 89%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
rename to mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
index 74d001a5e..f9a97849f 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
@@ -1,20 +1,8 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.client.transport;
+package io.modelcontextprotocol.client.transport;
import java.io.BufferedReader;
import java.io.IOException;
@@ -23,13 +11,16 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
-import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
+import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
@@ -38,11 +29,6 @@
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
-import org.springframework.ai.mcp.spec.ClientMcpTransport;
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage;
-import org.springframework.ai.mcp.util.Assert;
-
/**
* Implementation of the MCP Stdio transport that communicates with a server process using
* standard input/output streams. Messages are exchanged as newline-delimited JSON-RPC
@@ -51,7 +37,7 @@
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
-public class StdioClientTransport implements ClientMcpTransport {
+public class StdioClientTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(StdioClientTransport.class);
@@ -81,7 +67,7 @@ public class StdioClientTransport implements ClientMcpTransport {
private volatile boolean isClosing = false;
// visible for tests
- private Consumer errorHandler = error -> logger.error("Error received: {}", error);
+ private Consumer stdErrorHandler = error -> logger.info("STDERR Message received: {}", error);
/**
* Creates a new StdioClientTransport with the specified parameters and default
@@ -177,8 +163,8 @@ protected ProcessBuilder getProcessBuilder() {
*
* @param errorHandler a consumer that processes error messages
*/
- public void setErrorHandler(Consumer errorHandler) {
- this.errorHandler = errorHandler;
+ public void setStdErrorHandler(Consumer errorHandler) {
+ this.stdErrorHandler = errorHandler;
}
/**
@@ -205,7 +191,6 @@ private void startErrorProcessing() {
String line;
while (!isClosing && (line = processErrorReader.readLine()) != null) {
try {
- logger.error("Received error line: {}", line);
if (!this.errorSink.tryEmitNext(line).isSuccess()) {
if (!isClosing) {
logger.error("Failed to emit error message");
@@ -243,7 +228,7 @@ private void handleIncomingMessages(Function, Mono {
- this.errorHandler.accept(e);
+ this.stdErrorHandler.accept(e);
});
}
@@ -367,14 +352,15 @@ public Mono closeGracefully() {
// Give a short time for any pending messages to be processed
return Mono.delay(Duration.ofMillis(100));
- })).then(Mono.fromFuture(() -> {
- logger.info("Sending TERM to process");
+ })).then(Mono.defer(() -> {
+ logger.debug("Sending TERM to process");
if (this.process != null) {
this.process.destroy();
- return process.onExit();
+ return Mono.fromFuture(process.onExit());
}
else {
- return CompletableFuture.failedFuture(new RuntimeException("Process not started"));
+ logger.warn("Process not started");
+ return Mono.empty();
}
})).doOnNext(process -> {
if (process.exitValue() != 0) {
@@ -388,7 +374,7 @@ public Mono closeGracefully() {
errorScheduler.dispose();
outboundScheduler.dispose();
- logger.info("Graceful shutdown completed");
+ logger.debug("Graceful shutdown completed");
}
catch (Exception e) {
logger.error("Error during graceful shutdown", e);
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
new file mode 100644
index 000000000..ef69539ad
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
@@ -0,0 +1,1510 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpClientSession;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import io.modelcontextprotocol.spec.McpServerSession;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
+import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.ServerMcpTransport;
+import io.modelcontextprotocol.util.Utils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * The Model Context Protocol (MCP) server implementation that provides asynchronous
+ * communication using Project Reactor's Mono and Flux types.
+ *
+ *
+ * This server implements the MCP specification, enabling AI models to expose tools,
+ * resources, and prompts through a standardized interface. Key features include:
+ *
+ * Asynchronous communication using reactive programming patterns
+ * Dynamic tool registration and management
+ * Resource handling with URI-based addressing
+ * Prompt template management
+ * Real-time client notifications for state changes
+ * Structured logging with configurable severity levels
+ * Support for client-side AI model sampling
+ *
+ *
+ *
+ * The server follows a lifecycle:
+ *
+ * Initialization - Accepts client connections and negotiates capabilities
+ * Normal Operation - Handles client requests and sends notifications
+ * Graceful Shutdown - Ensures clean connection termination
+ *
+ *
+ *
+ * This implementation uses Project Reactor for non-blocking operations, making it
+ * suitable for high-throughput scenarios and reactive applications. All operations return
+ * Mono or Flux types that can be composed into reactive pipelines.
+ *
+ *
+ * The server supports runtime modification of its capabilities through methods like
+ * {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying
+ * connected clients of changes when configured to do so.
+ *
+ * @author Christian Tzolov
+ * @author Dariusz Jędrzejczyk
+ * @see McpServer
+ * @see McpSchema
+ * @see McpClientSession
+ */
+public class McpAsyncServer {
+
+ private static final Logger logger = LoggerFactory.getLogger(McpAsyncServer.class);
+
+ private final McpAsyncServer delegate;
+
+ McpAsyncServer() {
+ this.delegate = null;
+ }
+
+ /**
+ * Create a new McpAsyncServer with the given transport and capabilities.
+ * @param mcpTransport The transport layer implementation for MCP communication.
+ * @param features The MCP server supported features.
+ * @deprecated This constructor will beremoved in 0.9.0. Use
+ * {@link #McpAsyncServer(McpServerTransportProvider, ObjectMapper, McpServerFeatures.Async)}
+ * instead.
+ */
+ @Deprecated
+ McpAsyncServer(ServerMcpTransport mcpTransport, McpServerFeatures.Async features) {
+ this.delegate = new LegacyAsyncServer(mcpTransport, features);
+ }
+
+ /**
+ * Create a new McpAsyncServer with the given transport provider and capabilities.
+ * @param mcpTransportProvider The transport layer implementation for MCP
+ * communication.
+ * @param features The MCP server supported features.
+ * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
+ */
+ McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
+ McpServerFeatures.Async features) {
+ this.delegate = new AsyncServerImpl(mcpTransportProvider, objectMapper, features);
+ }
+
+ /**
+ * Get the server capabilities that define the supported features and functionality.
+ * @return The server capabilities
+ */
+ public McpSchema.ServerCapabilities getServerCapabilities() {
+ return this.delegate.getServerCapabilities();
+ }
+
+ /**
+ * Get the server implementation information.
+ * @return The server implementation details
+ */
+ public McpSchema.Implementation getServerInfo() {
+ return this.delegate.getServerInfo();
+ }
+
+ /**
+ * Get the client capabilities that define the supported features and functionality.
+ * @return The client capabilities
+ * @deprecated This will be removed in 0.9.0. Use
+ * {@link McpAsyncServerExchange#getClientCapabilities()}.
+ */
+ @Deprecated
+ public ClientCapabilities getClientCapabilities() {
+ return this.delegate.getClientCapabilities();
+ }
+
+ /**
+ * Get the client implementation information.
+ * @return The client implementation details
+ * @deprecated This will be removed in 0.9.0. Use
+ * {@link McpAsyncServerExchange#getClientInfo()}.
+ */
+ @Deprecated
+ public McpSchema.Implementation getClientInfo() {
+ return this.delegate.getClientInfo();
+ }
+
+ /**
+ * Gracefully closes the server, allowing any in-progress operations to complete.
+ * @return A Mono that completes when the server has been closed
+ */
+ public Mono closeGracefully() {
+ return this.delegate.closeGracefully();
+ }
+
+ /**
+ * Close the server immediately.
+ */
+ public void close() {
+ this.delegate.close();
+ }
+
+ /**
+ * Retrieves the list of all roots provided by the client.
+ * @return A Mono that emits the list of roots result.
+ * @deprecated This will be removed in 0.9.0. Use
+ * {@link McpAsyncServerExchange#listRoots()}.
+ */
+ @Deprecated
+ public Mono listRoots() {
+ return this.delegate.listRoots(null);
+ }
+
+ /**
+ * Retrieves a paginated list of roots provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return A Mono that emits the list of roots result containing
+ * @deprecated This will be removed in 0.9.0. Use
+ * {@link McpAsyncServerExchange#listRoots(String)}.
+ */
+ @Deprecated
+ public Mono listRoots(String cursor) {
+ return this.delegate.listRoots(cursor);
+ }
+
+ // ---------------------------------------
+ // Tool Management
+ // ---------------------------------------
+
+ /**
+ * Add a new tool registration at runtime.
+ * @param toolRegistration The tool registration to add
+ * @return Mono that completes when clients have been notified of the change
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #addTool(McpServerFeatures.AsyncToolSpecification)}.
+ */
+ @Deprecated
+ public Mono addTool(McpServerFeatures.AsyncToolRegistration toolRegistration) {
+ return this.delegate.addTool(toolRegistration);
+ }
+
+ /**
+ * Add a new tool specification at runtime.
+ * @param toolSpecification The tool specification to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
+ return this.delegate.addTool(toolSpecification);
+ }
+
+ /**
+ * Remove a tool handler at runtime.
+ * @param toolName The name of the tool handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removeTool(String toolName) {
+ return this.delegate.removeTool(toolName);
+ }
+
+ /**
+ * Notifies clients that the list of available tools has changed.
+ * @return A Mono that completes when all clients have been notified
+ */
+ public Mono notifyToolsListChanged() {
+ return this.delegate.notifyToolsListChanged();
+ }
+
+ // ---------------------------------------
+ // Resource Management
+ // ---------------------------------------
+
+ /**
+ * Add a new resource handler at runtime.
+ * @param resourceHandler The resource handler to add
+ * @return Mono that completes when clients have been notified of the change
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #addResource(McpServerFeatures.AsyncResourceSpecification)}.
+ */
+ @Deprecated
+ public Mono addResource(McpServerFeatures.AsyncResourceRegistration resourceHandler) {
+ return this.delegate.addResource(resourceHandler);
+ }
+
+ /**
+ * Add a new resource handler at runtime.
+ * @param resourceHandler The resource handler to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono addResource(McpServerFeatures.AsyncResourceSpecification resourceHandler) {
+ return this.delegate.addResource(resourceHandler);
+ }
+
+ /**
+ * Remove a resource handler at runtime.
+ * @param resourceUri The URI of the resource handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removeResource(String resourceUri) {
+ return this.delegate.removeResource(resourceUri);
+ }
+
+ /**
+ * Notifies clients that the list of available resources has changed.
+ * @return A Mono that completes when all clients have been notified
+ */
+ public Mono notifyResourcesListChanged() {
+ return this.delegate.notifyResourcesListChanged();
+ }
+
+ // ---------------------------------------
+ // Prompt Management
+ // ---------------------------------------
+
+ /**
+ * Add a new prompt handler at runtime.
+ * @param promptRegistration The prompt handler to add
+ * @return Mono that completes when clients have been notified of the change
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #addPrompt(McpServerFeatures.AsyncPromptSpecification)}.
+ */
+ @Deprecated
+ public Mono addPrompt(McpServerFeatures.AsyncPromptRegistration promptRegistration) {
+ return this.delegate.addPrompt(promptRegistration);
+ }
+
+ /**
+ * Add a new prompt handler at runtime.
+ * @param promptSpecification The prompt handler to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
+ return this.delegate.addPrompt(promptSpecification);
+ }
+
+ /**
+ * Remove a prompt handler at runtime.
+ * @param promptName The name of the prompt handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removePrompt(String promptName) {
+ return this.delegate.removePrompt(promptName);
+ }
+
+ /**
+ * Notifies clients that the list of available prompts has changed.
+ * @return A Mono that completes when all clients have been notified
+ */
+ public Mono notifyPromptsListChanged() {
+ return this.delegate.notifyPromptsListChanged();
+ }
+
+ // ---------------------------------------
+ // Logging Management
+ // ---------------------------------------
+
+ /**
+ * Send a logging message notification to all connected clients. Messages below the
+ * current minimum logging level will be filtered out.
+ * @param loggingMessageNotification The logging message to send
+ * @return A Mono that completes when the notification has been sent
+ */
+ public Mono loggingNotification(LoggingMessageNotification loggingMessageNotification) {
+ return this.delegate.loggingNotification(loggingMessageNotification);
+ }
+
+ // ---------------------------------------
+ // Sampling
+ // ---------------------------------------
+
+ /**
+ * Create a new message using the sampling capabilities of the client. The Model
+ * Context Protocol (MCP) provides a standardized way for servers to request LLM
+ * sampling (“completions” or “generations”) from language models via clients. This
+ * flow allows clients to maintain control over model access, selection, and
+ * permissions while enabling servers to leverage AI capabilities—with no server API
+ * keys necessary. Servers can request text or image-based interactions and optionally
+ * include context from MCP servers in their prompts.
+ * @param createMessageRequest The request to create a new message
+ * @return A Mono that completes when the message has been created
+ * @throws McpError if the client has not been initialized or does not support
+ * sampling capabilities
+ * @throws McpError if the client does not support the createMessage method
+ * @see McpSchema.CreateMessageRequest
+ * @see McpSchema.CreateMessageResult
+ * @see Sampling
+ * Specification
+ * @deprecated This will be removed in 0.9.0. Use
+ * {@link McpAsyncServerExchange#createMessage(McpSchema.CreateMessageRequest)}.
+ */
+ @Deprecated
+ public Mono createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
+ return this.delegate.createMessage(createMessageRequest);
+ }
+
+ /**
+ * This method is package-private and used for test only. Should not be called by user
+ * code.
+ * @param protocolVersions the Client supported protocol versions.
+ */
+ void setProtocolVersions(List protocolVersions) {
+ this.delegate.setProtocolVersions(protocolVersions);
+ }
+
+ private static class AsyncServerImpl extends McpAsyncServer {
+
+ private final McpServerTransportProvider mcpTransportProvider;
+
+ private final ObjectMapper objectMapper;
+
+ private final McpSchema.ServerCapabilities serverCapabilities;
+
+ private final McpSchema.Implementation serverInfo;
+
+ private final CopyOnWriteArrayList tools = new CopyOnWriteArrayList<>();
+
+ private final CopyOnWriteArrayList resourceTemplates = new CopyOnWriteArrayList<>();
+
+ private final ConcurrentHashMap resources = new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap prompts = new ConcurrentHashMap<>();
+
+ private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
+
+ private List protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
+
+ AsyncServerImpl(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
+ McpServerFeatures.Async features) {
+ this.mcpTransportProvider = mcpTransportProvider;
+ this.objectMapper = objectMapper;
+ this.serverInfo = features.serverInfo();
+ this.serverCapabilities = features.serverCapabilities();
+ this.tools.addAll(features.tools());
+ this.resources.putAll(features.resources());
+ this.resourceTemplates.addAll(features.resourceTemplates());
+ this.prompts.putAll(features.prompts());
+
+ Map> requestHandlers = new HashMap<>();
+
+ // Initialize request handlers for standard MCP methods
+
+ // Ping MUST respond with an empty data, but not NULL response.
+ requestHandlers.put(McpSchema.METHOD_PING, (exchange, params) -> Mono.just(Map.of()));
+
+ // Add tools API handlers if the tool capability is enabled
+ if (this.serverCapabilities.tools() != null) {
+ requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
+ }
+
+ // Add resources API handlers if provided
+ if (this.serverCapabilities.resources() != null) {
+ requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());
+ }
+
+ // Add prompts API handlers if provider exists
+ if (this.serverCapabilities.prompts() != null) {
+ requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
+ }
+
+ // Add logging API handlers if the logging capability is enabled
+ if (this.serverCapabilities.logging() != null) {
+ requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
+ }
+
+ Map notificationHandlers = new HashMap<>();
+
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (exchange, params) -> Mono.empty());
+
+ List, Mono>> rootsChangeConsumers = features
+ .rootsChangeConsumers();
+
+ if (Utils.isEmpty(rootsChangeConsumers)) {
+ rootsChangeConsumers = List.of((exchange,
+ roots) -> Mono.fromRunnable(() -> logger.warn(
+ "Roots list changed notification, but no consumers provided. Roots list changed: {}",
+ roots)));
+ }
+
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,
+ asyncRootsListChangedNotificationHandler(rootsChangeConsumers));
+
+ mcpTransportProvider
+ .setSessionFactory(transport -> new McpServerSession(UUID.randomUUID().toString(), transport,
+ this::asyncInitializeRequestHandler, Mono::empty, requestHandlers, notificationHandlers));
+ }
+
+ // ---------------------------------------
+ // Lifecycle Management
+ // ---------------------------------------
+ private Mono asyncInitializeRequestHandler(
+ McpSchema.InitializeRequest initializeRequest) {
+ return Mono.defer(() -> {
+ logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}",
+ initializeRequest.protocolVersion(), initializeRequest.capabilities(),
+ initializeRequest.clientInfo());
+
+ // The server MUST respond with the highest protocol version it supports
+ // if
+ // it does not support the requested (e.g. Client) version.
+ String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
+
+ if (this.protocolVersions.contains(initializeRequest.protocolVersion())) {
+ // If the server supports the requested protocol version, it MUST
+ // respond
+ // with the same version.
+ serverProtocolVersion = initializeRequest.protocolVersion();
+ }
+ else {
+ logger.warn(
+ "Client requested unsupported protocol version: {}, so the server will sugggest the {} version instead",
+ initializeRequest.protocolVersion(), serverProtocolVersion);
+ }
+
+ return Mono.just(new McpSchema.InitializeResult(serverProtocolVersion, this.serverCapabilities,
+ this.serverInfo, null));
+ });
+ }
+
+ public McpSchema.ServerCapabilities getServerCapabilities() {
+ return this.serverCapabilities;
+ }
+
+ public McpSchema.Implementation getServerInfo() {
+ return this.serverInfo;
+ }
+
+ @Override
+ @Deprecated
+ public ClientCapabilities getClientCapabilities() {
+ throw new IllegalStateException("This method is deprecated and should not be called");
+ }
+
+ @Override
+ @Deprecated
+ public McpSchema.Implementation getClientInfo() {
+ throw new IllegalStateException("This method is deprecated and should not be called");
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return this.mcpTransportProvider.closeGracefully();
+ }
+
+ @Override
+ public void close() {
+ this.mcpTransportProvider.close();
+ }
+
+ @Override
+ @Deprecated
+ public Mono listRoots() {
+ return this.listRoots(null);
+ }
+
+ @Override
+ @Deprecated
+ public Mono listRoots(String cursor) {
+ return Mono.error(new RuntimeException("Not implemented"));
+ }
+
+ private McpServerSession.NotificationHandler asyncRootsListChangedNotificationHandler(
+ List, Mono>> rootsChangeConsumers) {
+ return (exchange, params) -> exchange.listRoots()
+ .flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
+ .flatMap(consumer -> consumer.apply(exchange, listRootsResult.roots()))
+ .onErrorResume(error -> {
+ logger.error("Error handling roots list change notification", error);
+ return Mono.empty();
+ })
+ .then());
+ }
+
+ // ---------------------------------------
+ // Tool Management
+ // ---------------------------------------
+
+ @Override
+ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
+ if (toolSpecification == null) {
+ return Mono.error(new McpError("Tool specification must not be null"));
+ }
+ if (toolSpecification.tool() == null) {
+ return Mono.error(new McpError("Tool must not be null"));
+ }
+ if (toolSpecification.call() == null) {
+ return Mono.error(new McpError("Tool call handler must not be null"));
+ }
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server must be configured with tool capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ // Check for duplicate tool names
+ if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolSpecification.tool().name()))) {
+ return Mono
+ .error(new McpError("Tool with name '" + toolSpecification.tool().name() + "' already exists"));
+ }
+
+ this.tools.add(toolSpecification);
+ logger.debug("Added tool handler: {}", toolSpecification.tool().name());
+
+ if (this.serverCapabilities.tools().listChanged()) {
+ return notifyToolsListChanged();
+ }
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono addTool(McpServerFeatures.AsyncToolRegistration toolRegistration) {
+ return this.addTool(toolRegistration.toSpecification());
+ }
+
+ @Override
+ public Mono removeTool(String toolName) {
+ if (toolName == null) {
+ return Mono.error(new McpError("Tool name must not be null"));
+ }
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server must be configured with tool capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ boolean removed = this.tools
+ .removeIf(toolSpecification -> toolSpecification.tool().name().equals(toolName));
+ if (removed) {
+ logger.debug("Removed tool handler: {}", toolName);
+ if (this.serverCapabilities.tools().listChanged()) {
+ return notifyToolsListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
+ });
+ }
+
+ @Override
+ public Mono notifyToolsListChanged() {
+ return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
+ }
+
+ private McpServerSession.RequestHandler toolsListRequestHandler() {
+ return (exchange, params) -> {
+ List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
+
+ return Mono.just(new McpSchema.ListToolsResult(tools, null));
+ };
+ }
+
+ private McpServerSession.RequestHandler toolsCallRequestHandler() {
+ return (exchange, params) -> {
+ McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
+ new TypeReference() {
+ });
+
+ Optional toolSpecification = this.tools.stream()
+ .filter(tr -> callToolRequest.name().equals(tr.tool().name()))
+ .findAny();
+
+ if (toolSpecification.isEmpty()) {
+ return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
+ }
+
+ return toolSpecification.map(tool -> tool.call().apply(exchange, callToolRequest.arguments()))
+ .orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
+ };
+ }
+
+ // ---------------------------------------
+ // Resource Management
+ // ---------------------------------------
+
+ @Override
+ public Mono addResource(McpServerFeatures.AsyncResourceSpecification resourceSpecification) {
+ if (resourceSpecification == null || resourceSpecification.resource() == null) {
+ return Mono.error(new McpError("Resource must not be null"));
+ }
+
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server must be configured with resource capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ if (this.resources.putIfAbsent(resourceSpecification.resource().uri(), resourceSpecification) != null) {
+ return Mono.error(new McpError(
+ "Resource with URI '" + resourceSpecification.resource().uri() + "' already exists"));
+ }
+ logger.debug("Added resource handler: {}", resourceSpecification.resource().uri());
+ if (this.serverCapabilities.resources().listChanged()) {
+ return notifyResourcesListChanged();
+ }
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono addResource(McpServerFeatures.AsyncResourceRegistration resourceHandler) {
+ return this.addResource(resourceHandler.toSpecification());
+ }
+
+ @Override
+ public Mono removeResource(String resourceUri) {
+ if (resourceUri == null) {
+ return Mono.error(new McpError("Resource URI must not be null"));
+ }
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server must be configured with resource capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ McpServerFeatures.AsyncResourceSpecification removed = this.resources.remove(resourceUri);
+ if (removed != null) {
+ logger.debug("Removed resource handler: {}", resourceUri);
+ if (this.serverCapabilities.resources().listChanged()) {
+ return notifyResourcesListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
+ });
+ }
+
+ @Override
+ public Mono notifyResourcesListChanged() {
+ return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
+ }
+
+ private McpServerSession.RequestHandler resourcesListRequestHandler() {
+ return (exchange, params) -> {
+ var resourceList = this.resources.values()
+ .stream()
+ .map(McpServerFeatures.AsyncResourceSpecification::resource)
+ .toList();
+ return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
+ };
+ }
+
+ private McpServerSession.RequestHandler resourceTemplateListRequestHandler() {
+ return (exchange, params) -> Mono
+ .just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
+
+ }
+
+ private McpServerSession.RequestHandler resourcesReadRequestHandler() {
+ return (exchange, params) -> {
+ McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
+ new TypeReference() {
+ });
+ var resourceUri = resourceRequest.uri();
+ McpServerFeatures.AsyncResourceSpecification specification = this.resources.get(resourceUri);
+ if (specification != null) {
+ return specification.readHandler().apply(exchange, resourceRequest);
+ }
+ return Mono.error(new McpError("Resource not found: " + resourceUri));
+ };
+ }
+
+ // ---------------------------------------
+ // Prompt Management
+ // ---------------------------------------
+
+ @Override
+ public Mono addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
+ if (promptSpecification == null) {
+ return Mono.error(new McpError("Prompt specification must not be null"));
+ }
+ if (this.serverCapabilities.prompts() == null) {
+ return Mono.error(new McpError("Server must be configured with prompt capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ McpServerFeatures.AsyncPromptSpecification specification = this.prompts
+ .putIfAbsent(promptSpecification.prompt().name(), promptSpecification);
+ if (specification != null) {
+ return Mono.error(new McpError(
+ "Prompt with name '" + promptSpecification.prompt().name() + "' already exists"));
+ }
+
+ logger.debug("Added prompt handler: {}", promptSpecification.prompt().name());
+
+ // Servers that declared the listChanged capability SHOULD send a
+ // notification,
+ // when the list of available prompts changes
+ if (this.serverCapabilities.prompts().listChanged()) {
+ return notifyPromptsListChanged();
+ }
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono addPrompt(McpServerFeatures.AsyncPromptRegistration promptRegistration) {
+ return this.addPrompt(promptRegistration.toSpecification());
+ }
+
+ @Override
+ public Mono removePrompt(String promptName) {
+ if (promptName == null) {
+ return Mono.error(new McpError("Prompt name must not be null"));
+ }
+ if (this.serverCapabilities.prompts() == null) {
+ return Mono.error(new McpError("Server must be configured with prompt capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ McpServerFeatures.AsyncPromptSpecification removed = this.prompts.remove(promptName);
+
+ if (removed != null) {
+ logger.debug("Removed prompt handler: {}", promptName);
+ // Servers that declared the listChanged capability SHOULD send a
+ // notification, when the list of available prompts changes
+ if (this.serverCapabilities.prompts().listChanged()) {
+ return this.notifyPromptsListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
+ });
+ }
+
+ @Override
+ public Mono notifyPromptsListChanged() {
+ return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
+ }
+
+ private McpServerSession.RequestHandler promptsListRequestHandler() {
+ return (exchange, params) -> {
+ // TODO: Implement pagination
+ // McpSchema.PaginatedRequest request = objectMapper.convertValue(params,
+ // new TypeReference() {
+ // });
+
+ var promptList = this.prompts.values()
+ .stream()
+ .map(McpServerFeatures.AsyncPromptSpecification::prompt)
+ .toList();
+
+ return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
+ };
+ }
+
+ private McpServerSession.RequestHandler promptsGetRequestHandler() {
+ return (exchange, params) -> {
+ McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params,
+ new TypeReference() {
+ });
+
+ // Implement prompt retrieval logic here
+ McpServerFeatures.AsyncPromptSpecification specification = this.prompts.get(promptRequest.name());
+ if (specification == null) {
+ return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
+ }
+
+ return specification.promptHandler().apply(exchange, promptRequest);
+ };
+ }
+
+ // ---------------------------------------
+ // Logging Management
+ // ---------------------------------------
+
+ @Override
+ public Mono loggingNotification(LoggingMessageNotification loggingMessageNotification) {
+
+ if (loggingMessageNotification == null) {
+ return Mono.error(new McpError("Logging message must not be null"));
+ }
+
+ Map params = this.objectMapper.convertValue(loggingMessageNotification,
+ new TypeReference>() {
+ });
+
+ if (loggingMessageNotification.level().level() < minLoggingLevel.level()) {
+ return Mono.empty();
+ }
+
+ return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_MESSAGE, params);
+ }
+
+ private McpServerSession.RequestHandler setLoggerRequestHandler() {
+ return (exchange, params) -> {
+ this.minLoggingLevel = objectMapper.convertValue(params, new TypeReference() {
+ });
+
+ return Mono.empty();
+ };
+ }
+
+ // ---------------------------------------
+ // Sampling
+ // ---------------------------------------
+
+ @Override
+ @Deprecated
+ public Mono createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
+ return Mono.error(new RuntimeException("Not implemented"));
+ }
+
+ @Override
+ void setProtocolVersions(List protocolVersions) {
+ this.protocolVersions = protocolVersions;
+ }
+
+ }
+
+ private static final class LegacyAsyncServer extends McpAsyncServer {
+
+ /**
+ * The MCP session implementation that manages bidirectional JSON-RPC
+ * communication between clients and servers.
+ */
+ private final McpClientSession mcpSession;
+
+ private final ServerMcpTransport transport;
+
+ private final McpSchema.ServerCapabilities serverCapabilities;
+
+ private final McpSchema.Implementation serverInfo;
+
+ private McpSchema.ClientCapabilities clientCapabilities;
+
+ private McpSchema.Implementation clientInfo;
+
+ /**
+ * Thread-safe list of tool handlers that can be modified at runtime.
+ */
+ private final CopyOnWriteArrayList tools = new CopyOnWriteArrayList<>();
+
+ private final CopyOnWriteArrayList resourceTemplates = new CopyOnWriteArrayList<>();
+
+ private final ConcurrentHashMap resources = new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap prompts = new ConcurrentHashMap<>();
+
+ private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
+
+ /**
+ * Supported protocol versions.
+ */
+ private List protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
+
+ /**
+ * Create a new McpAsyncServer with the given transport and capabilities.
+ * @param mcpTransport The transport layer implementation for MCP communication.
+ * @param features The MCP server supported features.
+ */
+ LegacyAsyncServer(ServerMcpTransport mcpTransport, McpServerFeatures.Async features) {
+
+ this.serverInfo = features.serverInfo();
+ this.serverCapabilities = features.serverCapabilities();
+ this.tools.addAll(features.tools());
+ this.resources.putAll(features.resources());
+ this.resourceTemplates.addAll(features.resourceTemplates());
+ this.prompts.putAll(features.prompts());
+
+ Map> requestHandlers = new HashMap<>();
+
+ // Initialize request handlers for standard MCP methods
+ requestHandlers.put(McpSchema.METHOD_INITIALIZE, asyncInitializeRequestHandler());
+
+ // Ping MUST respond with an empty data, but not NULL response.
+ requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just(Map.of()));
+
+ // Add tools API handlers if the tool capability is enabled
+ if (this.serverCapabilities.tools() != null) {
+ requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
+ }
+
+ // Add resources API handlers if provided
+ if (this.serverCapabilities.resources() != null) {
+ requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());
+ }
+
+ // Add prompts API handlers if provider exists
+ if (this.serverCapabilities.prompts() != null) {
+ requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
+ requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
+ }
+
+ // Add logging API handlers if the logging capability is enabled
+ if (this.serverCapabilities.logging() != null) {
+ requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
+ }
+
+ Map notificationHandlers = new HashMap<>();
+
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (params) -> Mono.empty());
+
+ List, Mono>> rootsChangeHandlers = features
+ .rootsChangeConsumers();
+
+ List, Mono>> rootsChangeConsumers = rootsChangeHandlers.stream()
+ .map(handler -> (Function, Mono>) (roots) -> handler.apply(null, roots))
+ .toList();
+
+ if (Utils.isEmpty(rootsChangeConsumers)) {
+ rootsChangeConsumers = List.of((roots) -> Mono.fromRunnable(() -> logger.warn(
+ "Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)));
+ }
+
+ notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,
+ asyncRootsListChangedNotificationHandler(rootsChangeConsumers));
+
+ this.transport = mcpTransport;
+ this.mcpSession = new McpClientSession(Duration.ofSeconds(10), mcpTransport, requestHandlers,
+ notificationHandlers);
+ }
+
+ @Override
+ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
+ throw new IllegalArgumentException(
+ "McpAsyncServer configured with legacy " + "transport. Use McpServerTransportProvider instead.");
+ }
+
+ @Override
+ public Mono addResource(McpServerFeatures.AsyncResourceSpecification resourceHandler) {
+ throw new IllegalArgumentException(
+ "McpAsyncServer configured with legacy " + "transport. Use McpServerTransportProvider instead.");
+ }
+
+ @Override
+ public Mono addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
+ throw new IllegalArgumentException(
+ "McpAsyncServer configured with legacy " + "transport. Use McpServerTransportProvider instead.");
+ }
+
+ // ---------------------------------------
+ // Lifecycle Management
+ // ---------------------------------------
+ private McpClientSession.RequestHandler asyncInitializeRequestHandler() {
+ return params -> {
+ McpSchema.InitializeRequest initializeRequest = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+ this.clientCapabilities = initializeRequest.capabilities();
+ this.clientInfo = initializeRequest.clientInfo();
+ logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}",
+ initializeRequest.protocolVersion(), initializeRequest.capabilities(),
+ initializeRequest.clientInfo());
+
+ // The server MUST respond with the highest protocol version it supports
+ // if
+ // it does not support the requested (e.g. Client) version.
+ String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
+
+ if (this.protocolVersions.contains(initializeRequest.protocolVersion())) {
+ // If the server supports the requested protocol version, it MUST
+ // respond
+ // with the same version.
+ serverProtocolVersion = initializeRequest.protocolVersion();
+ }
+ else {
+ logger.warn(
+ "Client requested unsupported protocol version: {}, so the server will sugggest the {} version instead",
+ initializeRequest.protocolVersion(), serverProtocolVersion);
+ }
+
+ return Mono.just(new McpSchema.InitializeResult(serverProtocolVersion, this.serverCapabilities,
+ this.serverInfo, null));
+ };
+ }
+
+ /**
+ * Get the server capabilities that define the supported features and
+ * functionality.
+ * @return The server capabilities
+ */
+ public McpSchema.ServerCapabilities getServerCapabilities() {
+ return this.serverCapabilities;
+ }
+
+ /**
+ * Get the server implementation information.
+ * @return The server implementation details
+ */
+ public McpSchema.Implementation getServerInfo() {
+ return this.serverInfo;
+ }
+
+ /**
+ * Get the client capabilities that define the supported features and
+ * functionality.
+ * @return The client capabilities
+ */
+ public ClientCapabilities getClientCapabilities() {
+ return this.clientCapabilities;
+ }
+
+ /**
+ * Get the client implementation information.
+ * @return The client implementation details
+ */
+ public McpSchema.Implementation getClientInfo() {
+ return this.clientInfo;
+ }
+
+ /**
+ * Gracefully closes the server, allowing any in-progress operations to complete.
+ * @return A Mono that completes when the server has been closed
+ */
+ public Mono closeGracefully() {
+ return this.mcpSession.closeGracefully();
+ }
+
+ /**
+ * Close the server immediately.
+ */
+ public void close() {
+ this.mcpSession.close();
+ }
+
+ private static final TypeReference LIST_ROOTS_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ /**
+ * Retrieves the list of all roots provided by the client.
+ * @return A Mono that emits the list of roots result.
+ */
+ public Mono listRoots() {
+ return this.listRoots(null);
+ }
+
+ /**
+ * Retrieves a paginated list of roots provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return A Mono that emits the list of roots result containing
+ */
+ public Mono listRoots(String cursor) {
+ return this.mcpSession.sendRequest(McpSchema.METHOD_ROOTS_LIST, new McpSchema.PaginatedRequest(cursor),
+ LIST_ROOTS_RESULT_TYPE_REF);
+ }
+
+ private McpClientSession.NotificationHandler asyncRootsListChangedNotificationHandler(
+ List, Mono>> rootsChangeConsumers) {
+ return params -> listRoots().flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
+ .flatMap(consumer -> consumer.apply(listRootsResult.roots()))
+ .onErrorResume(error -> {
+ logger.error("Error handling roots list change notification", error);
+ return Mono.empty();
+ })
+ .then());
+ }
+
+ // ---------------------------------------
+ // Tool Management
+ // ---------------------------------------
+
+ /**
+ * Add a new tool registration at runtime.
+ * @param toolRegistration The tool registration to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ @Override
+ public Mono addTool(McpServerFeatures.AsyncToolRegistration toolRegistration) {
+ if (toolRegistration == null) {
+ return Mono.error(new McpError("Tool registration must not be null"));
+ }
+ if (toolRegistration.tool() == null) {
+ return Mono.error(new McpError("Tool must not be null"));
+ }
+ if (toolRegistration.call() == null) {
+ return Mono.error(new McpError("Tool call handler must not be null"));
+ }
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server must be configured with tool capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ // Check for duplicate tool names
+ if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolRegistration.tool().name()))) {
+ return Mono
+ .error(new McpError("Tool with name '" + toolRegistration.tool().name() + "' already exists"));
+ }
+
+ this.tools.add(toolRegistration.toSpecification());
+ logger.debug("Added tool handler: {}", toolRegistration.tool().name());
+
+ if (this.serverCapabilities.tools().listChanged()) {
+ return notifyToolsListChanged();
+ }
+ return Mono.empty();
+ });
+ }
+
+ /**
+ * Remove a tool handler at runtime.
+ * @param toolName The name of the tool handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removeTool(String toolName) {
+ if (toolName == null) {
+ return Mono.error(new McpError("Tool name must not be null"));
+ }
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server must be configured with tool capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ boolean removed = this.tools
+ .removeIf(toolRegistration -> toolRegistration.tool().name().equals(toolName));
+ if (removed) {
+ logger.debug("Removed tool handler: {}", toolName);
+ if (this.serverCapabilities.tools().listChanged()) {
+ return notifyToolsListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
+ });
+ }
+
+ /**
+ * Notifies clients that the list of available tools has changed.
+ * @return A Mono that completes when all clients have been notified
+ */
+ public Mono notifyToolsListChanged() {
+ return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
+ }
+
+ private McpClientSession.RequestHandler toolsListRequestHandler() {
+ return params -> {
+ List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
+
+ return Mono.just(new McpSchema.ListToolsResult(tools, null));
+ };
+ }
+
+ private McpClientSession.RequestHandler toolsCallRequestHandler() {
+ return params -> {
+ McpSchema.CallToolRequest callToolRequest = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ Optional toolRegistration = this.tools.stream()
+ .filter(tr -> callToolRequest.name().equals(tr.tool().name()))
+ .findAny();
+
+ if (toolRegistration.isEmpty()) {
+ return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
+ }
+
+ return toolRegistration.map(tool -> tool.call().apply(null, callToolRequest.arguments()))
+ .orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
+ };
+ }
+
+ // ---------------------------------------
+ // Resource Management
+ // ---------------------------------------
+
+ /**
+ * Add a new resource handler at runtime.
+ * @param resourceHandler The resource handler to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ @Override
+ public Mono addResource(McpServerFeatures.AsyncResourceRegistration resourceHandler) {
+ if (resourceHandler == null || resourceHandler.resource() == null) {
+ return Mono.error(new McpError("Resource must not be null"));
+ }
+
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server must be configured with resource capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ if (this.resources.putIfAbsent(resourceHandler.resource().uri(),
+ resourceHandler.toSpecification()) != null) {
+ return Mono.error(new McpError(
+ "Resource with URI '" + resourceHandler.resource().uri() + "' already exists"));
+ }
+ logger.debug("Added resource handler: {}", resourceHandler.resource().uri());
+ if (this.serverCapabilities.resources().listChanged()) {
+ return notifyResourcesListChanged();
+ }
+ return Mono.empty();
+ });
+ }
+
+ /**
+ * Remove a resource handler at runtime.
+ * @param resourceUri The URI of the resource handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removeResource(String resourceUri) {
+ if (resourceUri == null) {
+ return Mono.error(new McpError("Resource URI must not be null"));
+ }
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server must be configured with resource capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ McpServerFeatures.AsyncResourceSpecification removed = this.resources.remove(resourceUri);
+ if (removed != null) {
+ logger.debug("Removed resource handler: {}", resourceUri);
+ if (this.serverCapabilities.resources().listChanged()) {
+ return notifyResourcesListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
+ });
+ }
+
+ /**
+ * Notifies clients that the list of available resources has changed.
+ * @return A Mono that completes when all clients have been notified
+ */
+ public Mono notifyResourcesListChanged() {
+ return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
+ }
+
+ private McpClientSession.RequestHandler resourcesListRequestHandler() {
+ return params -> {
+ var resourceList = this.resources.values()
+ .stream()
+ .map(McpServerFeatures.AsyncResourceSpecification::resource)
+ .toList();
+ return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
+ };
+ }
+
+ private McpClientSession.RequestHandler resourceTemplateListRequestHandler() {
+ return params -> Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
+
+ }
+
+ private McpClientSession.RequestHandler resourcesReadRequestHandler() {
+ return params -> {
+ McpSchema.ReadResourceRequest resourceRequest = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+ var resourceUri = resourceRequest.uri();
+ McpServerFeatures.AsyncResourceSpecification registration = this.resources.get(resourceUri);
+ if (registration != null) {
+ return registration.readHandler().apply(null, resourceRequest);
+ }
+ return Mono.error(new McpError("Resource not found: " + resourceUri));
+ };
+ }
+
+ // ---------------------------------------
+ // Prompt Management
+ // ---------------------------------------
+
+ /**
+ * Add a new prompt handler at runtime.
+ * @param promptRegistration The prompt handler to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ @Override
+ public Mono addPrompt(McpServerFeatures.AsyncPromptRegistration promptRegistration) {
+ if (promptRegistration == null) {
+ return Mono.error(new McpError("Prompt registration must not be null"));
+ }
+ if (this.serverCapabilities.prompts() == null) {
+ return Mono.error(new McpError("Server must be configured with prompt capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ McpServerFeatures.AsyncPromptSpecification registration = this.prompts
+ .putIfAbsent(promptRegistration.prompt().name(), promptRegistration.toSpecification());
+ if (registration != null) {
+ return Mono.error(new McpError(
+ "Prompt with name '" + promptRegistration.prompt().name() + "' already exists"));
+ }
+
+ logger.debug("Added prompt handler: {}", promptRegistration.prompt().name());
+
+ // Servers that declared the listChanged capability SHOULD send a
+ // notification,
+ // when the list of available prompts changes
+ if (this.serverCapabilities.prompts().listChanged()) {
+ return notifyPromptsListChanged();
+ }
+ return Mono.empty();
+ });
+ }
+
+ /**
+ * Remove a prompt handler at runtime.
+ * @param promptName The name of the prompt handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removePrompt(String promptName) {
+ if (promptName == null) {
+ return Mono.error(new McpError("Prompt name must not be null"));
+ }
+ if (this.serverCapabilities.prompts() == null) {
+ return Mono.error(new McpError("Server must be configured with prompt capabilities"));
+ }
+
+ return Mono.defer(() -> {
+ McpServerFeatures.AsyncPromptSpecification removed = this.prompts.remove(promptName);
+
+ if (removed != null) {
+ logger.debug("Removed prompt handler: {}", promptName);
+ // Servers that declared the listChanged capability SHOULD send a
+ // notification, when the list of available prompts changes
+ if (this.serverCapabilities.prompts().listChanged()) {
+ return this.notifyPromptsListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
+ });
+ }
+
+ /**
+ * Notifies clients that the list of available prompts has changed.
+ * @return A Mono that completes when all clients have been notified
+ */
+ public Mono notifyPromptsListChanged() {
+ return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
+ }
+
+ private McpClientSession.RequestHandler promptsListRequestHandler() {
+ return params -> {
+ // TODO: Implement pagination
+ // McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ // new TypeReference() {
+ // });
+
+ var promptList = this.prompts.values()
+ .stream()
+ .map(McpServerFeatures.AsyncPromptSpecification::prompt)
+ .toList();
+
+ return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
+ };
+ }
+
+ private McpClientSession.RequestHandler promptsGetRequestHandler() {
+ return params -> {
+ McpSchema.GetPromptRequest promptRequest = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ // Implement prompt retrieval logic here
+ McpServerFeatures.AsyncPromptSpecification registration = this.prompts.get(promptRequest.name());
+ if (registration == null) {
+ return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
+ }
+
+ return registration.promptHandler().apply(null, promptRequest);
+ };
+ }
+
+ // ---------------------------------------
+ // Logging Management
+ // ---------------------------------------
+
+ /**
+ * Send a logging message notification to all connected clients. Messages below
+ * the current minimum logging level will be filtered out.
+ * @param loggingMessageNotification The logging message to send
+ * @return A Mono that completes when the notification has been sent
+ */
+ public Mono loggingNotification(LoggingMessageNotification loggingMessageNotification) {
+
+ if (loggingMessageNotification == null) {
+ return Mono.error(new McpError("Logging message must not be null"));
+ }
+
+ Map params = this.transport.unmarshalFrom(loggingMessageNotification,
+ new TypeReference>() {
+ });
+
+ if (loggingMessageNotification.level().level() < minLoggingLevel.level()) {
+ return Mono.empty();
+ }
+
+ return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, params);
+ }
+
+ /**
+ * Handles requests to set the minimum logging level. Messages below this level
+ * will not be sent.
+ * @return A handler that processes logging level change requests
+ */
+ private McpClientSession.RequestHandler setLoggerRequestHandler() {
+ return params -> {
+ this.minLoggingLevel = transport.unmarshalFrom(params, new TypeReference() {
+ });
+
+ return Mono.empty();
+ };
+ }
+
+ // ---------------------------------------
+ // Sampling
+ // ---------------------------------------
+ private static final TypeReference CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ /**
+ * Create a new message using the sampling capabilities of the client. The Model
+ * Context Protocol (MCP) provides a standardized way for servers to request LLM
+ * sampling (“completions” or “generations”) from language models via clients.
+ * This flow allows clients to maintain control over model access, selection, and
+ * permissions while enabling servers to leverage AI capabilities—with no server
+ * API keys necessary. Servers can request text or image-based interactions and
+ * optionally include context from MCP servers in their prompts.
+ * @param createMessageRequest The request to create a new message
+ * @return A Mono that completes when the message has been created
+ * @throws McpError if the client has not been initialized or does not support
+ * sampling capabilities
+ * @throws McpError if the client does not support the createMessage method
+ * @see McpSchema.CreateMessageRequest
+ * @see McpSchema.CreateMessageResult
+ * @see Sampling
+ * Specification
+ */
+ public Mono createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
+
+ if (this.clientCapabilities == null) {
+ return Mono.error(new McpError("Client must be initialized. Call the initialize method first!"));
+ }
+ if (this.clientCapabilities.sampling() == null) {
+ return Mono.error(new McpError("Client must be configured with sampling capabilities"));
+ }
+ return this.mcpSession.sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, createMessageRequest,
+ CREATE_MESSAGE_RESULT_TYPE_REF);
+ }
+
+ /**
+ * This method is package-private and used for test only. Should not be called by
+ * user code.
+ * @param protocolVersions the Client supported protocol versions.
+ */
+ void setProtocolVersions(List protocolVersions) {
+ this.protocolVersions = protocolVersions;
+ }
+
+ }
+
+}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java
new file mode 100644
index 000000000..658628448
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java
@@ -0,0 +1,104 @@
+package io.modelcontextprotocol.server;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpServerSession;
+import reactor.core.publisher.Mono;
+
+/**
+ * Represents an asynchronous exchange with a Model Context Protocol (MCP) client. The
+ * exchange provides methods to interact with the client and query its capabilities.
+ *
+ * @author Dariusz Jędrzejczyk
+ */
+public class McpAsyncServerExchange {
+
+ private final McpServerSession session;
+
+ private final McpSchema.ClientCapabilities clientCapabilities;
+
+ private final McpSchema.Implementation clientInfo;
+
+ private static final TypeReference CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ private static final TypeReference LIST_ROOTS_RESULT_TYPE_REF = new TypeReference<>() {
+ };
+
+ /**
+ * Create a new asynchronous exchange with the client.
+ * @param session The server session representing a 1-1 interaction.
+ * @param clientCapabilities The client capabilities that define the supported
+ * features and functionality.
+ * @param clientInfo The client implementation information.
+ */
+ public McpAsyncServerExchange(McpServerSession session, McpSchema.ClientCapabilities clientCapabilities,
+ McpSchema.Implementation clientInfo) {
+ this.session = session;
+ this.clientCapabilities = clientCapabilities;
+ this.clientInfo = clientInfo;
+ }
+
+ /**
+ * Get the client capabilities that define the supported features and functionality.
+ * @return The client capabilities
+ */
+ public McpSchema.ClientCapabilities getClientCapabilities() {
+ return this.clientCapabilities;
+ }
+
+ /**
+ * Get the client implementation information.
+ * @return The client implementation details
+ */
+ public McpSchema.Implementation getClientInfo() {
+ return this.clientInfo;
+ }
+
+ /**
+ * Create a new message using the sampling capabilities of the client. The Model
+ * Context Protocol (MCP) provides a standardized way for servers to request LLM
+ * sampling (“completions” or “generations”) from language models via clients. This
+ * flow allows clients to maintain control over model access, selection, and
+ * permissions while enabling servers to leverage AI capabilities—with no server API
+ * keys necessary. Servers can request text or image-based interactions and optionally
+ * include context from MCP servers in their prompts.
+ * @param createMessageRequest The request to create a new message
+ * @return A Mono that completes when the message has been created
+ * @see McpSchema.CreateMessageRequest
+ * @see McpSchema.CreateMessageResult
+ * @see Sampling
+ * Specification
+ */
+ public Mono createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
+ if (this.clientCapabilities == null) {
+ return Mono.error(new McpError("Client must be initialized. Call the initialize method first!"));
+ }
+ if (this.clientCapabilities.sampling() == null) {
+ return Mono.error(new McpError("Client must be configured with sampling capabilities"));
+ }
+ return this.session.sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, createMessageRequest,
+ CREATE_MESSAGE_RESULT_TYPE_REF);
+ }
+
+ /**
+ * Retrieves the list of all roots provided by the client.
+ * @return A Mono that emits the list of roots result.
+ */
+ public Mono listRoots() {
+ return this.listRoots(null);
+ }
+
+ /**
+ * Retrieves a paginated list of roots provided by the client.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return A Mono that emits the list of roots result containing
+ */
+ public Mono listRoots(String cursor) {
+ return this.session.sendRequest(McpSchema.METHOD_ROOTS_LIST, new McpSchema.PaginatedRequest(cursor),
+ LIST_ROOTS_RESULT_TYPE_REF);
+ }
+
+}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java
similarity index 57%
rename from mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
rename to mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java
index 615fd515d..d8dfcb018 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java
@@ -1,38 +1,28 @@
/*
* Copyright 2024-2024 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
*/
-package org.springframework.ai.mcp.server;
+package io.modelcontextprotocol.server;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import io.modelcontextprotocol.spec.ServerMcpTransport;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
+import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
-import reactor.core.scheduler.Schedulers;
-
-import org.springframework.ai.mcp.spec.McpSchema;
-import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
-import org.springframework.ai.mcp.spec.McpSchema.ResourceTemplate;
-import org.springframework.ai.mcp.spec.McpTransport;
-import org.springframework.ai.mcp.spec.ServerMcpTransport;
-import org.springframework.ai.mcp.util.Assert;
/**
* Factory class for creating Model Context Protocol (MCP) servers. MCP servers expose
@@ -64,45 +54,50 @@
*
* The class provides factory methods to create either:
*
- * {@link McpAsyncServer} for non-blocking operations with CompletableFuture responses
+ * {@link McpAsyncServer} for non-blocking operations with reactive responses
* {@link McpSyncServer} for blocking operations with direct responses
*
*
*
* Example of creating a basic synchronous server:
{@code
- * McpServer.sync(transport)
+ * McpServer.sync(transportProvider)
* .serverInfo("my-server", "1.0.0")
* .tool(new Tool("calculator", "Performs calculations", schema),
- * args -> new CallToolResult("Result: " + calculate(args)))
+ * (exchange, args) -> new CallToolResult("Result: " + calculate(args)))
* .build();
* }
*
* Example of creating a basic asynchronous server: {@code
- * McpServer.async(transport)
+ * McpServer.async(transportProvider)
* .serverInfo("my-server", "1.0.0")
* .tool(new Tool("calculator", "Performs calculations", schema),
- * args -> Mono.just(new CallToolResult("Result: " + calculate(args))))
+ * (exchange, args) -> Mono.fromSupplier(() -> calculate(args))
+ * .map(result -> new CallToolResult("Result: " + result)))
* .build();
* }
*
*
* Example with comprehensive asynchronous configuration:
{@code
- * McpServer.async(transport)
+ * McpServer.async(transportProvider)
* .serverInfo("advanced-server", "2.0.0")
* .capabilities(new ServerCapabilities(...))
* // Register tools
* .tools(
- * new McpServerFeatures.AsyncToolRegistration(calculatorTool,
- * args -> Mono.just(new CallToolResult("Result: " + calculate(args)))),
- * new McpServerFeatures.AsyncToolRegistration(weatherTool,
- * args -> Mono.just(new CallToolResult("Weather: " + getWeather(args))))
+ * new McpServerFeatures.AsyncToolSpecification(calculatorTool,
+ * (exchange, args) -> Mono.fromSupplier(() -> calculate(args))
+ * .map(result -> new CallToolResult("Result: " + result))),
+ * new McpServerFeatures.AsyncToolSpecification(weatherTool,
+ * (exchange, args) -> Mono.fromSupplier(() -> getWeather(args))
+ * .map(result -> new CallToolResult("Weather: " + result)))
* )
* // Register resources
* .resources(
- * new McpServerFeatures.AsyncResourceRegistration(fileResource,
- * req -> Mono.just(new ReadResourceResult(readFile(req)))),
- * new McpServerFeatures.AsyncResourceRegistration(dbResource,
- * req -> Mono.just(new ReadResourceResult(queryDb(req))))
+ * new McpServerFeatures.AsyncResourceSpecification(fileResource,
+ * (exchange, req) -> Mono.fromSupplier(() -> readFile(req))
+ * .map(ReadResourceResult::new)),
+ * new McpServerFeatures.AsyncResourceSpecification(dbResource,
+ * (exchange, req) -> Mono.fromSupplier(() -> queryDb(req))
+ * .map(ReadResourceResult::new))
* )
* // Add resource templates
* .resourceTemplates(
@@ -111,10 +106,12 @@
* )
* // Register prompts
* .prompts(
- * new McpServerFeatures.AsyncPromptRegistration(analysisPrompt,
- * req -> Mono.just(new GetPromptResult(generateAnalysisPrompt(req)))),
+ * new McpServerFeatures.AsyncPromptSpecification(analysisPrompt,
+ * (exchange, req) -> Mono.fromSupplier(() -> generateAnalysisPrompt(req))
+ * .map(GetPromptResult::new)),
* new McpServerFeatures.AsyncPromptRegistration(summaryPrompt,
- * req -> Mono.just(new GetPromptResult(generateSummaryPrompt(req))))
+ * (exchange, req) -> Mono.fromSupplier(() -> generateSummaryPrompt(req))
+ * .map(GetPromptResult::new))
* )
* .build();
* }
@@ -123,55 +120,75 @@
* @author Dariusz Jędrzejczyk
* @see McpAsyncServer
* @see McpSyncServer
- * @see McpTransport
+ * @see McpServerTransportProvider
*/
public interface McpServer {
/**
* Starts building a synchronous MCP server that provides blocking operations.
- * Synchronous servers process each request to completion before handling the next
- * one, making them simpler to implement but potentially less performant for
- * concurrent operations.
+ * Synchronous servers block the current Thread's execution upon each request before
+ * giving the control back to the caller, making them simpler to implement but
+ * potentially less scalable for concurrent operations.
+ * @param transportProvider The transport layer implementation for MCP communication.
+ * @return A new instance of {@link SyncSpecification} for configuring the server.
+ */
+ static SyncSpecification sync(McpServerTransportProvider transportProvider) {
+ return new SyncSpecification(transportProvider);
+ }
+
+ /**
+ * Starts building a synchronous MCP server that provides blocking operations.
+ * Synchronous servers block the current Thread's execution upon each request before
+ * giving the control back to the caller, making them simpler to implement but
+ * potentially less scalable for concurrent operations.
* @param transport The transport layer implementation for MCP communication
* @return A new instance of {@link SyncSpec} for configuring the server.
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #sync(McpServerTransportProvider)} instead.
*/
+ @Deprecated
static SyncSpec sync(ServerMcpTransport transport) {
return new SyncSpec(transport);
}
/**
- * Starts building an asynchronous MCP server that provides blocking operations.
- * Asynchronous servers can handle multiple requests concurrently using a functional
- * paradigm with non-blocking server transports, making them more efficient for
- * high-concurrency scenarios but more complex to implement.
- * @param transport The transport layer implementation for MCP communication
- * @return A new instance of {@link SyncSpec} for configuring the server.
+ * Starts building an asynchronous MCP server that provides non-blocking operations.
+ * Asynchronous servers can handle multiple requests concurrently on a single Thread
+ * using a functional paradigm with non-blocking server transports, making them more
+ * scalable for high-concurrency scenarios but more complex to implement.
+ * @param transportProvider The transport layer implementation for MCP communication.
+ * @return A new instance of {@link AsyncSpecification} for configuring the server.
*/
- static AsyncSpec async(ServerMcpTransport transport) {
- return new AsyncSpec(transport);
+ static AsyncSpecification async(McpServerTransportProvider transportProvider) {
+ return new AsyncSpecification(transportProvider);
}
/**
- * Start building an MCP server with the specified transport.
+ * Starts building an asynchronous MCP server that provides non-blocking operations.
+ * Asynchronous servers can handle multiple requests concurrently on a single Thread
+ * using a functional paradigm with non-blocking server transports, making them more
+ * scalable for high-concurrency scenarios but more complex to implement.
* @param transport The transport layer implementation for MCP communication
- * @return A new builder instance
- * @deprecated Use {@link #sync(ServerMcpTransport)} or
- * {@link #async(ServerMcpTransport)} to create a server instance.
+ * @return A new instance of {@link AsyncSpec} for configuring the server.
+ * @deprecated This method will be removed in 0.9.0. Use
+ * {@link #async(McpServerTransportProvider)} instead.
*/
@Deprecated
- public static Builder using(ServerMcpTransport transport) {
- return new Builder(transport);
+ static AsyncSpec async(ServerMcpTransport transport) {
+ return new AsyncSpec(transport);
}
/**
* Asynchronous server specification.
*/
- class AsyncSpec {
+ class AsyncSpecification {
private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server",
"1.0.0");
- private final ServerMcpTransport transport;
+ private final McpServerTransportProvider transportProvider;
+
+ private ObjectMapper objectMapper;
private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO;
@@ -184,7 +201,7 @@ class AsyncSpec {
* Each tool is uniquely identified by a name and includes metadata describing its
* schema.
*/
- private final List tools = new ArrayList<>();
+ private final List tools = new ArrayList<>();
/**
* The Model Context Protocol (MCP) provides a standardized way for servers to
@@ -193,7 +210,7 @@ class AsyncSpec {
* application-specific information. Each resource is uniquely identified by a
* URI.
*/
- private final Map resources = new HashMap<>();
+ private final Map resources = new HashMap<>();
private final List resourceTemplates = new ArrayList<>();
@@ -204,13 +221,13 @@ class AsyncSpec {
* discover available prompts, retrieve their contents, and provide arguments to
* customize them.
*/
- private final Map prompts = new HashMap<>();
+ private final Map prompts = new HashMap<>();
- private final List, Mono>> rootsChangeConsumers = new ArrayList<>();
+ private final List, Mono>> rootsChangeHandlers = new ArrayList<>();
- private AsyncSpec(ServerMcpTransport transport) {
- Assert.notNull(transport, "Transport must not be null");
- this.transport = transport;
+ private AsyncSpecification(McpServerTransportProvider transportProvider) {
+ Assert.notNull(transportProvider, "Transport provider must not be null");
+ this.transportProvider = transportProvider;
}
/**
@@ -222,7 +239,7 @@ private AsyncSpec(ServerMcpTransport transport) {
* @return This builder instance for method chaining
* @throws IllegalArgumentException if serverInfo is null
*/
- public AsyncSpec serverInfo(McpSchema.Implementation serverInfo) {
+ public AsyncSpecification serverInfo(McpSchema.Implementation serverInfo) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
return this;
@@ -238,7 +255,7 @@ public AsyncSpec serverInfo(McpSchema.Implementation serverInfo) {
* @throws IllegalArgumentException if name or version is null or empty
* @see #serverInfo(McpSchema.Implementation)
*/
- public AsyncSpec serverInfo(String name, String version) {
+ public AsyncSpecification serverInfo(String name, String version) {
Assert.hasText(name, "Name must not be null or empty");
Assert.hasText(version, "Version must not be null or empty");
this.serverInfo = new McpSchema.Implementation(name, version);
@@ -253,15 +270,14 @@ public AsyncSpec serverInfo(String name, String version) {
* Tool execution
* Resource access
* Prompt handling
- * Streaming responses
- * Batch operations
*
* @param serverCapabilities The server capabilities configuration. Must not be
* null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if serverCapabilities is null
*/
- public AsyncSpec capabilities(McpSchema.ServerCapabilities serverCapabilities) {
+ public AsyncSpecification capabilities(McpSchema.ServerCapabilities serverCapabilities) {
+ Assert.notNull(serverCapabilities, "Server capabilities must not be null");
this.serverCapabilities = serverCapabilities;
return this;
}
@@ -269,26 +285,31 @@ public AsyncSpec capabilities(McpSchema.ServerCapabilities serverCapabilities) {
/**
* Adds a single tool with its implementation handler to the server. This is a
* convenience method for registering individual tools without creating a
- * {@link McpServerFeatures.AsyncToolRegistration} explicitly.
+ * {@link McpServerFeatures.AsyncToolSpecification} explicitly.
*
*
* Example usage:
{@code
* .tool(
* new Tool("calculator", "Performs calculations", schema),
- * args -> Mono.just(new CallToolResult("Result: " + calculate(args)))
+ * (exchange, args) -> Mono.fromSupplier(() -> calculate(args))
+ * .map(result -> new CallToolResult("Result: " + result))
* )
* }
* @param tool The tool definition including name, description, and schema. Must
* not be null.
* @param handler The function that implements the tool's logic. Must not be null.
+ * The function's first argument is an {@link McpAsyncServerExchange} upon which
+ * the server can interact with the connected client. The second argument is the
+ * map of arguments passed to the tool.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if tool or handler is null
*/
- public AsyncSpec tool(McpSchema.Tool tool, Function, Mono> handler) {
+ public AsyncSpecification tool(McpSchema.Tool tool,
+ BiFunction, Mono> handler) {
Assert.notNull(tool, "Tool must not be null");
Assert.notNull(handler, "Handler must not be null");
- this.tools.add(new McpServerFeatures.AsyncToolRegistration(tool, handler));
+ this.tools.add(new McpServerFeatures.AsyncToolSpecification(tool, handler));
return this;
}
@@ -297,15 +318,15 @@ public AsyncSpec tool(McpSchema.Tool tool, Function, Mono toolRegistrations) {
- Assert.notNull(toolRegistrations, "Tool handlers list must not be null");
- this.tools.addAll(toolRegistrations);
+ public AsyncSpecification tools(List toolSpecifications) {
+ Assert.notNull(toolSpecifications, "Tool handlers list must not be null");
+ this.tools.addAll(toolSpecifications);
return this;
}
@@ -316,18 +337,19 @@ public AsyncSpec tools(List toolRegistr
*
* Example usage:
{@code
* .tools(
- * new McpServerFeatures.AsyncToolRegistration(calculatorTool, calculatorHandler),
- * new McpServerFeatures.AsyncToolRegistration(weatherTool, weatherHandler),
- * new McpServerFeatures.AsyncToolRegistration(fileManagerTool, fileManagerHandler)
+ * new McpServerFeatures.AsyncToolSpecification(calculatorTool, calculatorHandler),
+ * new McpServerFeatures.AsyncToolSpecification(weatherTool, weatherHandler),
+ * new McpServerFeatures.AsyncToolSpecification(fileManagerTool, fileManagerHandler)
* )
* }
- * @param toolRegistrations The tool registrations to add. Must not be null.
+ * @param toolSpecifications The tool specifications to add. Must not be null.
* @return This builder instance for method chaining
- * @throws IllegalArgumentException if toolRegistrations is null
+ * @throws IllegalArgumentException if toolSpecifications is null
* @see #tools(List)
*/
- public AsyncSpec tools(McpServerFeatures.AsyncToolRegistration... toolRegistrations) {
- for (McpServerFeatures.AsyncToolRegistration tool : toolRegistrations) {
+ public AsyncSpecification tools(McpServerFeatures.AsyncToolSpecification... toolSpecifications) {
+ Assert.notNull(toolSpecifications, "Tool handlers list must not be null");
+ for (McpServerFeatures.AsyncToolSpecification tool : toolSpecifications) {
this.tools.add(tool);
}
return this;
@@ -337,29 +359,31 @@ public AsyncSpec tools(McpServerFeatures.AsyncToolRegistration... toolRegistrati
* Registers multiple resources with their handlers using a Map. This method is
* useful when resources are dynamically generated or loaded from a configuration
* source.
- * @param resourceRegsitrations Map of resource name to registration. Must not be
- * null.
+ * @param resourceSpecifications Map of resource name to specification. Must not
+ * be null.
* @return This builder instance for method chaining
- * @throws IllegalArgumentException if resourceRegsitrations is null
- * @see #resources(McpServerFeatures.AsyncResourceRegistration...)
+ * @throws IllegalArgumentException if resourceSpecifications is null
+ * @see #resources(McpServerFeatures.AsyncResourceSpecification...)
*/
- public AsyncSpec resources(Map resourceRegsitrations) {
- Assert.notNull(resourceRegsitrations, "Resource handlers map must not be null");
- this.resources.putAll(resourceRegsitrations);
+ public AsyncSpecification resources(
+ Map resourceSpecifications) {
+ Assert.notNull(resourceSpecifications, "Resource handlers map must not be null");
+ this.resources.putAll(resourceSpecifications);
return this;
}
/**
* Registers multiple resources with their handlers using a List. This method is
* useful when resources need to be added in bulk from a collection.
- * @param resourceRegsitrations List of resource registrations. Must not be null.
+ * @param resourceSpecifications List of resource specifications. Must not be
+ * null.
* @return This builder instance for method chaining
- * @throws IllegalArgumentException if resourceRegsitrations is null
- * @see #resources(McpServerFeatures.AsyncResourceRegistration...)
+ * @throws IllegalArgumentException if resourceSpecifications is null
+ * @see #resources(McpServerFeatures.AsyncResourceSpecification...)
*/
- public AsyncSpec resources(List resourceRegsitrations) {
- Assert.notNull(resourceRegsitrations, "Resource handlers list must not be null");
- for (McpServerFeatures.AsyncResourceRegistration resource : resourceRegsitrations) {
+ public AsyncSpecification resources(List resourceSpecifications) {
+ Assert.notNull(resourceSpecifications, "Resource handlers list must not be null");
+ for (McpServerFeatures.AsyncResourceSpecification resource : resourceSpecifications) {
this.resources.put(resource.resource().uri(), resource);
}
return this;
@@ -372,19 +396,19 @@ public AsyncSpec resources(List res
*
* Example usage:
{@code
* .resources(
- * new McpServerFeatures.AsyncResourceRegistration(fileResource, fileHandler),
- * new McpServerFeatures.AsyncResourceRegistration(dbResource, dbHandler),
- * new McpServerFeatures.AsyncResourceRegistration(apiResource, apiHandler)
+ * new McpServerFeatures.AsyncResourceSpecification(fileResource, fileHandler),
+ * new McpServerFeatures.AsyncResourceSpecification(dbResource, dbHandler),
+ * new McpServerFeatures.AsyncResourceSpecification(apiResource, apiHandler)
* )
* }
- * @param resourceRegistrations The resource registrations to add. Must not be
+ * @param resourceSpecifications The resource specifications to add. Must not be
* null.
* @return This builder instance for method chaining
- * @throws IllegalArgumentException if resourceRegistrations is null
+ * @throws IllegalArgumentException if resourceSpecifications is null
*/
- public AsyncSpec resources(McpServerFeatures.AsyncResourceRegistration... resourceRegistrations) {
- Assert.notNull(resourceRegistrations, "Resource handlers list must not be null");
- for (McpServerFeatures.AsyncResourceRegistration resource : resourceRegistrations) {
+ public AsyncSpecification resources(McpServerFeatures.AsyncResourceSpecification... resourceSpecifications) {
+ Assert.notNull(resourceSpecifications, "Resource handlers list must not be null");
+ for (McpServerFeatures.AsyncResourceSpecification resource : resourceSpecifications) {
this.resources.put(resource.resource().uri(), resource);
}
return this;
@@ -404,9 +428,11 @@ public AsyncSpec resources(McpServerFeatures.AsyncResourceRegistration... resour
* @param resourceTemplates List of resource templates. If null, clears existing
* templates.
* @return This builder instance for method chaining
+ * @throws IllegalArgumentException if resourceTemplates is null.
* @see #resourceTemplates(ResourceTemplate...)
*/
- public AsyncSpec resourceTemplates(List resourceTemplates) {
+ public AsyncSpecification resourceTemplates(List resourceTemplates) {
+ Assert.notNull(resourceTemplates, "Resource templates must not be null");
this.resourceTemplates.addAll(resourceTemplates);
return this;
}
@@ -416,9 +442,11 @@ public AsyncSpec resourceTemplates(List resourceTemplates) {
* alternative to {@link #resourceTemplates(List)}.
* @param resourceTemplates The resource templates to set.
* @return This builder instance for method chaining
+ * @throws IllegalArgumentException if resourceTemplates is null.
* @see #resourceTemplates(List)
*/
- public AsyncSpec resourceTemplates(ResourceTemplate... resourceTemplates) {
+ public AsyncSpecification resourceTemplates(ResourceTemplate... resourceTemplates) {
+ Assert.notNull(resourceTemplates, "Resource templates must not be null");
for (ResourceTemplate resourceTemplate : resourceTemplates) {
this.resourceTemplates.add(resourceTemplate);
}
@@ -432,16 +460,18 @@ public AsyncSpec resourceTemplates(ResourceTemplate... resourceTemplates) {
*
*
* Example usage:
{@code
- * .prompts(Map.of("analysis", new McpServerFeatures.AsyncPromptRegistration(
+ * .prompts(Map.of("analysis", new McpServerFeatures.AsyncPromptSpecification(
* new Prompt("analysis", "Code analysis template"),
- * request -> Mono.just(new GetPromptResult(generateAnalysisPrompt(request)))
+ * request -> Mono.fromSupplier(() -> generateAnalysisPrompt(request))
+ * .map(GetPromptResult::new)
* )));
* }
- * @param prompts Map of prompt name to registration. Must not be null.
+ * @param prompts Map of prompt name to specification. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if prompts is null
*/
- public AsyncSpec prompts(Map prompts) {
+ public AsyncSpecification prompts(Map prompts) {
+ Assert.notNull(prompts, "Prompts map must not be null");
this.prompts.putAll(prompts);
return this;
}
@@ -449,13 +479,14 @@ public AsyncSpec prompts(Map
/**
* Registers multiple prompts with their handlers using a List. This method is
* useful when prompts need to be added in bulk from a collection.
- * @param prompts List of prompt registrations. Must not be null.
+ * @param prompts List of prompt specifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if prompts is null
- * @see #prompts(McpServerFeatures.AsyncPromptRegistration...)
+ * @see #prompts(McpServerFeatures.AsyncPromptSpecification...)
*/
- public AsyncSpec prompts(List prompts) {
- for (McpServerFeatures.AsyncPromptRegistration prompt : prompts) {
+ public AsyncSpecification prompts(List prompts) {
+ Assert.notNull(prompts, "Prompts list must not be null");
+ for (McpServerFeatures.AsyncPromptSpecification prompt : prompts) {
this.prompts.put(prompt.prompt().name(), prompt);
}
return this;
@@ -468,17 +499,18 @@ public AsyncSpec prompts(List prompts
*
* Example usage:
{@code
* .prompts(
- * new McpServerFeatures.AsyncPromptRegistration(analysisPrompt, analysisHandler),
- * new McpServerFeatures.AsyncPromptRegistration(summaryPrompt, summaryHandler),
- * new McpServerFeatures.AsyncPromptRegistration(reviewPrompt, reviewHandler)
+ * new McpServerFeatures.AsyncPromptSpecification(analysisPrompt, analysisHandler),
+ * new McpServerFeatures.AsyncPromptSpecification(summaryPrompt, summaryHandler),
+ * new McpServerFeatures.AsyncPromptSpecification(reviewPrompt, reviewHandler)
* )
* }
- * @param prompts The prompt registrations to add. Must not be null.
+ * @param prompts The prompt specifications to add. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if prompts is null
*/
- public AsyncSpec prompts(McpServerFeatures.AsyncPromptRegistration... prompts) {
- for (McpServerFeatures.AsyncPromptRegistration prompt : prompts) {
+ public AsyncSpecification prompts(McpServerFeatures.AsyncPromptSpecification... prompts) {
+ Assert.notNull(prompts, "Prompts list must not be null");
+ for (McpServerFeatures.AsyncPromptSpecification prompt : prompts) {
this.prompts.put(prompt.prompt().name(), prompt);
}
return this;
@@ -488,13 +520,16 @@ public AsyncSpec prompts(McpServerFeatures.AsyncPromptRegistration... prompts) {
* Registers a consumer that will be notified when the list of roots changes. This
* is useful for updating resource availability dynamically, such as when new
* files are added or removed.
- * @param consumer The consumer to register. Must not be null.
+ * @param handler The handler to register. Must not be null. The function's first
+ * argument is an {@link McpAsyncServerExchange} upon which the server can
+ * interact with the connected client. The second argument is the list of roots.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if consumer is null
*/
- public AsyncSpec rootsChangeConsumer(Function, Mono> consumer) {
- Assert.notNull(consumer, "Consumer must not be null");
- this.rootsChangeConsumers.add(consumer);
+ public AsyncSpecification rootsChangeHandler(
+ BiFunction, Mono> handler) {
+ Assert.notNull(handler, "Consumer must not be null");
+ this.rootsChangeHandlers.add(handler);
return this;
}
@@ -502,13 +537,15 @@ public AsyncSpec rootsChangeConsumer(Function, Mono>
* Registers multiple consumers that will be notified when the list of roots
* changes. This method is useful when multiple consumers need to be registered at
* once.
- * @param consumers The list of consumers to register. Must not be null.
+ * @param handlers The list of handlers to register. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if consumers is null
+ * @see #rootsChangeHandler(BiFunction)
*/
- public AsyncSpec rootsChangeConsumers(List, Mono>> consumers) {
- Assert.notNull(consumers, "Consumers list must not be null");
- this.rootsChangeConsumers.addAll(consumers);
+ public AsyncSpecification rootsChangeHandlers(
+ List, Mono>> handlers) {
+ Assert.notNull(handlers, "Handlers list must not be null");
+ this.rootsChangeHandlers.addAll(handlers);
return this;
}
@@ -516,26 +553,39 @@ public AsyncSpec rootsChangeConsumers(List, Mono, Mono>... consumers) {
- for (Function, Mono> consumer : consumers) {
- this.rootsChangeConsumers.add(consumer);
- }
+ public AsyncSpecification rootsChangeHandlers(
+ @SuppressWarnings("unchecked") BiFunction, Mono>... handlers) {
+ Assert.notNull(handlers, "Handlers list must not be null");
+ return this.rootsChangeHandlers(Arrays.asList(handlers));
+ }
+
+ /**
+ * Sets the object mapper to use for serializing and deserializing JSON messages.
+ * @param objectMapper the instance to use. Must not be null.
+ * @return This builder instance for method chaining.
+ * @throws IllegalArgumentException if objectMapper is null
+ */
+ public AsyncSpecification objectMapper(ObjectMapper objectMapper) {
+ Assert.notNull(objectMapper, "ObjectMapper must not be null");
+ this.objectMapper = objectMapper;
return this;
}
/**
* Builds an asynchronous MCP server that provides non-blocking operations.
* @return A new instance of {@link McpAsyncServer} configured with this builder's
- * settings
+ * settings.
*/
public McpAsyncServer build() {
- return new McpAsyncServer(this.transport,
- new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources,
- this.resourceTemplates, this.prompts, this.rootsChangeConsumers));
+ var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
+ this.resources, this.resourceTemplates, this.prompts, this.rootsChangeHandlers);
+ var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
+ return new McpAsyncServer(this.transportProvider, mapper, features);
}
}
@@ -543,12 +593,14 @@ public McpAsyncServer build() {
/**
* Synchronous server specification.
*/
- class SyncSpec {
+ class SyncSpecification {
private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server",
"1.0.0");
- private final ServerMcpTransport transport;
+ private final McpServerTransportProvider transportProvider;
+
+ private ObjectMapper objectMapper;
private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO;
@@ -561,7 +613,7 @@ class SyncSpec {
* Each tool is uniquely identified by a name and includes metadata describing its
* schema.
*/
- private final List tools = new ArrayList<>();
+ private final List tools = new ArrayList<>();
/**
* The Model Context Protocol (MCP) provides a standardized way for servers to
@@ -570,7 +622,7 @@ class SyncSpec {
* application-specific information. Each resource is uniquely identified by a
* URI.
*/
- private final Map resources = new HashMap<>();
+ private final Map resources = new HashMap<>();
private final List resourceTemplates = new ArrayList<>();
@@ -581,13 +633,13 @@ class SyncSpec {
* discover available prompts, retrieve their contents, and provide arguments to
* customize them.
*/
- private final Map prompts = new HashMap<>();
+ private final Map prompts = new HashMap<>();
- private final List>> rootsChangeConsumers = new ArrayList<>();
+ private final List>> rootsChangeHandlers = new ArrayList<>();
- private SyncSpec(ServerMcpTransport transport) {
- Assert.notNull(transport, "Transport must not be null");
- this.transport = transport;
+ private SyncSpecification(McpServerTransportProvider transportProvider) {
+ Assert.notNull(transportProvider, "Transport provider must not be null");
+ this.transportProvider = transportProvider;
}
/**
@@ -599,7 +651,7 @@ private SyncSpec(ServerMcpTransport transport) {
* @return This builder instance for method chaining
* @throws IllegalArgumentException if serverInfo is null
*/
- public SyncSpec serverInfo(McpSchema.Implementation serverInfo) {
+ public SyncSpecification serverInfo(McpSchema.Implementation serverInfo) {
Assert.notNull(serverInfo, "Server info must not be null");
this.serverInfo = serverInfo;
return this;
@@ -615,7 +667,7 @@ public SyncSpec serverInfo(McpSchema.Implementation serverInfo) {
* @throws IllegalArgumentException if name or version is null or empty
* @see #serverInfo(McpSchema.Implementation)
*/
- public SyncSpec serverInfo(String name, String version) {
+ public SyncSpecification serverInfo(String name, String version) {
Assert.hasText(name, "Name must not be null or empty");
Assert.hasText(version, "Version must not be null or empty");
this.serverInfo = new McpSchema.Implementation(name, version);
@@ -630,15 +682,14 @@ public SyncSpec serverInfo(String name, String version) {
* Tool execution
* Resource access
* Prompt handling
- * Streaming responses
- * Batch operations
*
* @param serverCapabilities The server capabilities configuration. Must not be
* null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if serverCapabilities is null
*/
- public SyncSpec capabilities(McpSchema.ServerCapabilities serverCapabilities) {
+ public SyncSpecification capabilities(McpSchema.ServerCapabilities serverCapabilities) {
+ Assert.notNull(serverCapabilities, "Server capabilities must not be null");
this.serverCapabilities = serverCapabilities;
return this;
}
@@ -646,26 +697,30 @@ public SyncSpec capabilities(McpSchema.ServerCapabilities serverCapabilities) {
/**
* Adds a single tool with its implementation handler to the server. This is a
* convenience method for registering individual tools without creating a
- * {@link ToolRegistration} explicitly.
+ * {@link McpServerFeatures.SyncToolSpecification} explicitly.
*
*