contextExtractor) {
+ Assert.notNull(contextExtractor, "Context extractor must not be null");
+ this.contextExtractor = contextExtractor;
+ return this;
+ }
+
/**
* Sets the interval for keep-alive pings.
*
@@ -606,7 +659,7 @@ public HttpServletSseServerTransportProvider build() {
throw new IllegalStateException("MessageEndpoint must be set");
}
return new HttpServletSseServerTransportProvider(objectMapper, baseUrl, messageEndpoint, sseEndpoint,
- keepAliveInterval);
+ keepAliveInterval, contextExtractor);
}
}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
index 6805bf194..8b95ec607 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
@@ -28,6 +28,7 @@
import io.modelcontextprotocol.spec.McpStreamableServerSession;
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
+import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.KeepAliveScheduler;
import jakarta.servlet.AsyncContext;
@@ -155,8 +156,8 @@ private HttpServletStreamableServerTransportProvider(ObjectMapper objectMapper,
}
@Override
- public String protocolVersion() {
- return "2025-03-26";
+ public List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26);
}
@Override
diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
index d2943b31d..af602f610 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
@@ -10,6 +10,7 @@
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
+import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
@@ -22,6 +23,7 @@
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransport;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
+import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -89,8 +91,8 @@ public StdioServerTransportProvider(ObjectMapper objectMapper, InputStream input
}
@Override
- public String protocolVersion() {
- return "2024-11-05";
+ public List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2024_11_05);
}
@Override
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java
index cd8fc9659..f4bdc02eb 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidator.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
+
package io.modelcontextprotocol.spec;
import java.util.Map;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java
index 8533e69cf..f497afd43 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java
@@ -1,8 +1,11 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
-import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportSession.java
index 56cdeaf7f..fdb7bfd89 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportSession.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import org.reactivestreams.Publisher;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportStream.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportStream.java
index eb2b7edeb..8d63fb50d 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportStream.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpTransportStream.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import org.reactivestreams.Publisher;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java b/mcp/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java
index c1c4c7a7d..65b80957c 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/HttpHeaders.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
/**
@@ -15,7 +19,7 @@ public interface HttpHeaders {
/**
* Identifies events within an SSE Stream.
*/
- String LAST_EVENT_ID = "last-event-id";
+ String LAST_EVENT_ID = "Last-Event-ID";
/**
* Identifies the MCP protocol version.
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java b/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java
index c95e627a9..572d7c043 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/JsonSchemaValidator.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
+
package io.modelcontextprotocol.spec;
import java.util.Map;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java
index cc7d2abf8..f7db3d7aa 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java
@@ -221,7 +221,7 @@ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification noti
return Mono.defer(() -> {
var handler = notificationHandlers.get(notification.method());
if (handler == null) {
- logger.error("No handler registered for notification method: {}", notification.method());
+ logger.warn("No handler registered for notification method: {}", notification);
return Mono.empty();
}
return handler.handle(notification.params());
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientTransport.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientTransport.java
index 5c3b33131..22aec831b 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpClientTransport.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
+
package io.modelcontextprotocol.spec;
import java.util.function.Consumer;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpError.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpError.java
index 13e43240b..6172d8637 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpError.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpError.java
@@ -1,9 +1,11 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
+
package io.modelcontextprotocol.spec;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCResponse.JSONRPCError;
+import io.modelcontextprotocol.util.Assert;
public class McpError extends RuntimeException {
@@ -14,6 +16,7 @@ public McpError(JSONRPCError jsonRpcError) {
this.jsonRpcError = jsonRpcError;
}
+ @Deprecated
public McpError(Object error) {
super(error.toString());
}
@@ -22,4 +25,37 @@ public JSONRPCError getJsonRpcError() {
return jsonRpcError;
}
+ public static Builder builder(int errorCode) {
+ return new Builder(errorCode);
+ }
+
+ public static class Builder {
+
+ private final int code;
+
+ private String message;
+
+ private Object data;
+
+ private Builder(int code) {
+ this.code = code;
+ }
+
+ public Builder message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public Builder data(Object data) {
+ this.data = data;
+ return this;
+ }
+
+ public McpError build() {
+ Assert.hasText(message, "message must not be empty");
+ return new McpError(new JSONRPCError(code, message, data));
+ }
+
+ }
+
}
\ No newline at end of file
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpLoggableSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpLoggableSession.java
index ebc6e0949..f43a2c1d9 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpLoggableSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpLoggableSession.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
index fb4baabfb..3f8150271 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
@@ -45,7 +45,7 @@ private McpSchema() {
}
@Deprecated
- public static final String LATEST_PROTOCOL_VERSION = "2025-03-26";
+ public static final String LATEST_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_03_26;
public static final String JSONRPC_VERSION = "2.0";
@@ -523,6 +523,21 @@ public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe,
public record ToolCapabilities(@JsonProperty("listChanged") Boolean listChanged) {
}
+ /**
+ * Create a mutated copy of this object with the specified changes.
+ * @return A new Builder instance with the same values as this object.
+ */
+ public Builder mutate() {
+ var builder = new Builder();
+ builder.completions = this.completions;
+ builder.experimental = this.experimental;
+ builder.logging = this.logging;
+ builder.prompts = this.prompts;
+ builder.resources = this.resources;
+ builder.tools = this.tools;
+ return builder;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -533,7 +548,7 @@ public static class Builder {
private Map experimental;
- private LoggingCapabilities logging = new LoggingCapabilities();
+ private LoggingCapabilities logging;
private PromptCapabilities prompts;
@@ -2336,6 +2351,22 @@ public PromptReference(String name) {
public String identifier() {
return name();
}
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null || getClass() != obj.getClass())
+ return false;
+ PromptReference that = (PromptReference) obj;
+ return java.util.Objects.equals(identifier(), that.identifier())
+ && java.util.Objects.equals(type(), that.type());
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hash(identifier(), type());
+ }
}
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java
index 0b0ef01cd..e562ca012 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import java.time.Duration;
@@ -21,7 +25,7 @@
import reactor.core.publisher.Sinks;
/**
- * Represents a Model Control Protocol (MCP) session on the server side. It manages
+ * Represents a Model Context Protocol (MCP) session on the server side. It manages
* bidirectional JSON-RPC communication with the client.
*/
public class McpServerSession implements McpLoggableSession {
@@ -194,7 +198,9 @@ public Mono sendNotification(String method, Object params) {
* @return a Mono that completes when the message is processed
*/
public Mono handle(McpSchema.JSONRPCMessage message) {
- return Mono.defer(() -> {
+ return Mono.deferContextual(ctx -> {
+ McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
+
// TODO handle errors for communication to without initialization happening
// first
if (message instanceof McpSchema.JSONRPCResponse response) {
@@ -210,7 +216,7 @@ public Mono handle(McpSchema.JSONRPCMessage message) {
}
else if (message instanceof McpSchema.JSONRPCRequest request) {
logger.debug("Received request: {}", request);
- return handleIncomingRequest(request).onErrorResume(error -> {
+ return handleIncomingRequest(request, transportContext).onErrorResume(error -> {
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
error.getMessage(), null));
@@ -223,7 +229,7 @@ else if (message instanceof McpSchema.JSONRPCNotification notification) {
// happening first
logger.debug("Received notification: {}", notification);
// TODO: in case of error, should the POST request be signalled?
- return handleIncomingNotification(notification)
+ return handleIncomingNotification(notification, transportContext)
.doOnError(error -> logger.error("Error handling notification: {}", error.getMessage()));
}
else {
@@ -236,9 +242,11 @@ else if (message instanceof McpSchema.JSONRPCNotification notification) {
/**
* Handles an incoming JSON-RPC request by routing it to the appropriate handler.
* @param request The incoming JSON-RPC request
+ * @param transportContext
* @return A Mono containing the JSON-RPC response
*/
- private Mono handleIncomingRequest(McpSchema.JSONRPCRequest request) {
+ private Mono handleIncomingRequest(McpSchema.JSONRPCRequest request,
+ McpTransportContext transportContext) {
return Mono.defer(() -> {
Mono> resultMono;
if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
@@ -262,7 +270,8 @@ private Mono handleIncomingRequest(McpSchema.JSONRPCR
error.message(), error.data())));
}
- resultMono = this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, request.params()));
+ resultMono = this.exchangeSink.asMono()
+ .flatMap(exchange -> handler.handle(copyExchange(exchange, transportContext), request.params()));
}
return resultMono
.map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
@@ -276,27 +285,42 @@ private Mono handleIncomingRequest(McpSchema.JSONRPCR
/**
* Handles an incoming JSON-RPC notification by routing it to the appropriate handler.
* @param notification The incoming JSON-RPC notification
+ * @param transportContext
* @return A Mono that completes when the notification is processed
*/
- private Mono handleIncomingNotification(McpSchema.JSONRPCNotification notification) {
+ private Mono handleIncomingNotification(McpSchema.JSONRPCNotification notification,
+ McpTransportContext transportContext) {
return Mono.defer(() -> {
if (McpSchema.METHOD_NOTIFICATION_INITIALIZED.equals(notification.method())) {
this.state.lazySet(STATE_INITIALIZED);
// FIXME: The session ID passed here is not the same as the one in the
// legacy SSE transport.
exchangeSink.tryEmitValue(new McpAsyncServerExchange(this.id, this, clientCapabilities.get(),
- clientInfo.get(), McpTransportContext.EMPTY));
+ clientInfo.get(), transportContext));
}
var handler = notificationHandlers.get(notification.method());
if (handler == null) {
- logger.error("No handler registered for notification method: {}", notification.method());
+ logger.warn("No handler registered for notification method: {}", notification);
return Mono.empty();
}
- return this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, notification.params()));
+ return this.exchangeSink.asMono()
+ .flatMap(exchange -> handler.handle(copyExchange(exchange, transportContext), notification.params()));
});
}
+ /**
+ * This legacy implementation assumes an exchange is established upon the
+ * initialization phase see: exchangeSink.tryEmitValue(...), which creates a cached
+ * immutable exchange. Here, we create a new exchange and copy over everything from
+ * that cached exchange, and use it for a single HTTP request, with the transport
+ * context passed in.
+ */
+ private McpAsyncServerExchange copyExchange(McpAsyncServerExchange exchange, McpTransportContext transportContext) {
+ return new McpAsyncServerExchange(exchange.sessionId(), this, exchange.getClientCapabilities(),
+ exchange.getClientInfo(), transportContext);
+ }
+
record MethodNotFoundError(String method, String message, Object data) {
}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransport.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransport.java
index 632b8cee6..39c1644e0 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransport.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProvider.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProvider.java
index 382c0153b..02028ccdf 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProvider.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProvider.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java
index 798575017..acb1ecac6 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java
@@ -1,5 +1,10 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
+import java.util.List;
import java.util.Map;
import reactor.core.publisher.Mono;
@@ -59,8 +64,8 @@ default void close() {
* Returns the protocol version supported by this transport provider.
* @return the protocol version as a string
*/
- default String protocolVersion() {
- return "2024-11-05";
+ default List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2024_11_05);
}
}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSession.java
index 7b29ca651..3473a4da8 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSession.java
@@ -5,11 +5,10 @@
package io.modelcontextprotocol.spec;
import com.fasterxml.jackson.core.type.TypeReference;
-import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
/**
- * Represents a Model Control Protocol (MCP) session that handles communication between
+ * Represents a Model Context Protocol (MCP) session that handles communication between
* clients and the server. This interface provides methods for sending requests and
* notifications, as well as managing the session lifecycle.
*
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java
index 329908469..c1234b130 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java
@@ -1,5 +1,11 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
+import java.util.List;
+
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import reactor.core.publisher.Mono;
@@ -22,8 +28,8 @@ default void close() {
*/
Mono closeGracefully();
- default String protocolVersion() {
- return "2025-03-26";
+ default List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2025_03_26);
}
}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
index c9b041fd6..ef7967c1e 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import java.time.Duration;
@@ -193,7 +197,7 @@ public Mono accept(McpSchema.JSONRPCNotification notification) {
McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
McpNotificationHandler notificationHandler = this.notificationHandlers.get(notification.method());
if (notificationHandler == null) {
- logger.error("No handler registered for notification method: {}", notification.method());
+ logger.warn("No handler registered for notification method: {}", notification);
return Mono.empty();
}
McpLoggableSession listeningStream = this.listeningStreamRef.get();
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransport.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransport.java
index 39e90ce86..f53c68900 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransport.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import reactor.core.publisher.Mono;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransportProvider.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransportProvider.java
index b75081096..09fe9fb0e 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransportProvider.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerTransportProvider.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import reactor.core.publisher.Mono;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransport.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransport.java
index 49c485059..1922548a6 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransport.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransport.java
@@ -4,6 +4,8 @@
package io.modelcontextprotocol.spec;
+import java.util.List;
+
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import reactor.core.publisher.Mono;
@@ -77,8 +79,8 @@ default void close() {
*/
T unmarshalFrom(Object data, TypeReference typeRef);
- default String protocolVersion() {
- return "2024-11-05";
+ default List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2024_11_05);
}
}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportException.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportException.java
new file mode 100644
index 000000000..cfd3dae31
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportException.java
@@ -0,0 +1,38 @@
+/*
+* Copyright 2025 - 2025 the original author or authors.
+*/
+package io.modelcontextprotocol.spec;
+
+/**
+ * Exception thrown when there is an issue with the transport layer of the Model Context
+ * Protocol (MCP).
+ *
+ *
+ * This exception is used to indicate errors that occur during communication between the
+ * MCP client and server, such as connection failures, protocol violations, or unexpected
+ * responses.
+ *
+ * @author Christian Tzolov
+ */
+public class McpTransportException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public McpTransportException(String message) {
+ super(message);
+ }
+
+ public McpTransportException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public McpTransportException(Throwable cause) {
+ super(cause);
+ }
+
+ public McpTransportException(String message, Throwable cause, boolean enableSuppression,
+ boolean writableStackTrace) {
+ super(message, cause, enableSuppression, writableStackTrace);
+ }
+
+}
\ No newline at end of file
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSession.java
index 555f018f8..716ff0d16 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSession.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import org.reactivestreams.Publisher;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionNotFoundException.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionNotFoundException.java
index 474a18ae0..eced49ec3 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionNotFoundException.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionNotFoundException.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportStream.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportStream.java
index 2d6dcce75..322afda63 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportStream.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpTransportStream.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import org.reactivestreams.Publisher;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/MissingMcpTransportSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/MissingMcpTransportSession.java
index c83f0bead..aa33a8167 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/spec/MissingMcpTransportSession.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/MissingMcpTransportSession.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
import com.fasterxml.jackson.core.type.TypeReference;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/ProtocolVersions.java b/mcp/src/main/java/io/modelcontextprotocol/spec/ProtocolVersions.java
new file mode 100644
index 000000000..d8cb913a5
--- /dev/null
+++ b/mcp/src/main/java/io/modelcontextprotocol/spec/ProtocolVersions.java
@@ -0,0 +1,23 @@
+package io.modelcontextprotocol.spec;
+
+public interface ProtocolVersions {
+
+ /**
+ * MCP protocol version for 2024-11-05.
+ * https://modelcontextprotocol.io/specification/2024-11-05
+ */
+ String MCP_2024_11_05 = "2024-11-05";
+
+ /**
+ * MCP protocol version for 2025-03-26.
+ * https://modelcontextprotocol.io/specification/2025-03-26
+ */
+ String MCP_2025_03_26 = "2025-03-26";
+
+ /**
+ * MCP protocol version for 2025-06-18.
+ * https://modelcontextprotocol.io/specification/2025-06-18
+ */
+ String MCP_2025_06_18 = "2025-06-18";
+
+}
diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/DeafaultMcpUriTemplateManagerFactory.java b/mcp/src/main/java/io/modelcontextprotocol/util/DeafaultMcpUriTemplateManagerFactory.java
index 3870b76fc..44ea31690 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/util/DeafaultMcpUriTemplateManagerFactory.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/util/DeafaultMcpUriTemplateManagerFactory.java
@@ -1,6 +1,7 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
+
package io.modelcontextprotocol.util;
/**
diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java b/mcp/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
index 30e8a2c2a..9d411cd41 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java
@@ -1,6 +1,7 @@
/**
* Copyright 2025 - 2025 the original author or authors.
*/
+
package io.modelcontextprotocol.util;
import java.time.Duration;
diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/McpUriTemplateManagerFactory.java b/mcp/src/main/java/io/modelcontextprotocol/util/McpUriTemplateManagerFactory.java
index 9644f9a6c..389727b45 100644
--- a/mcp/src/main/java/io/modelcontextprotocol/util/McpUriTemplateManagerFactory.java
+++ b/mcp/src/main/java/io/modelcontextprotocol/util/McpUriTemplateManagerFactory.java
@@ -1,6 +1,7 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
+
package io.modelcontextprotocol.util;
/**
diff --git a/mcp/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java
index b531d5739..b1113a6d0 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java
@@ -45,8 +45,8 @@ public MockMcpClientTransport withProtocolVersion(String protocolVersion) {
}
@Override
- public String protocolVersion() {
- return protocolVersion;
+ public List protocolVersions() {
+ return List.of(protocolVersion);
}
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
diff --git a/mcp/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java b/mcp/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java
index 7ba35bbf0..e955be89f 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java
@@ -1,18 +1,7 @@
/*
-* Copyright 2025 - 2025 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.
-*/
+ * Copyright 2025-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol;
import io.modelcontextprotocol.spec.McpSchema;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java
index b673ed612..ec23e21dc 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
+
package io.modelcontextprotocol.client;
import eu.rekawek.toxiproxy.Proxy;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java
index e912e1dd6..3626d8ca0 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java
@@ -487,7 +487,8 @@ void testAddRoot() {
void testAddRootWithNullValue() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.addRoot(null))
- .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null"))
+ .consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Root must not be null"))
.verify();
});
}
@@ -506,7 +507,7 @@ void testRemoveRoot() {
void testRemoveNonExistentRoot() {
withClient(createMcpTransport(), mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri"))
- .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
+ .consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
.hasMessage("Root with uri 'nonexistent-uri' not found"))
.verify();
});
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java
index aa081b51b..aef2ab8dd 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.client;
import org.junit.jupiter.api.Timeout;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java
index 8285f417f..7f00de60e 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.client;
import org.junit.jupiter.api.Timeout;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java
index c8d691924..02021edbf 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/LifecycleInitializerTests.java
@@ -16,7 +16,6 @@
import org.mockito.MockitoAnnotations;
import io.modelcontextprotocol.spec.McpClientSession;
-import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import reactor.core.publisher.Mono;
@@ -154,7 +153,7 @@ void shouldFailForUnsupportedProtocolVersion() {
.thenReturn(Mono.just(unsupportedResult));
StepVerifier.create(initializer.withIntitialization("test", init -> Mono.just(init.initializeResult())))
- .expectError(McpError.class)
+ .expectError(RuntimeException.class)
.verify();
verify(mockClientSession, never()).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_INITIALIZED), any());
@@ -178,7 +177,7 @@ void shouldTimeoutOnSlowInitialization() {
init -> Mono.just(init.initializeResult())), () -> virtualTimeScheduler, Long.MAX_VALUE)
.expectSubscription()
.expectNoEvent(INITIALIZE_TIMEOUT)
- .expectError(McpError.class)
+ .expectError(RuntimeException.class)
.verify();
}
@@ -234,7 +233,7 @@ void shouldHandleInitializationFailure() {
.thenReturn(Mono.error(new RuntimeException("Connection failed")));
StepVerifier.create(initializer.withIntitialization("test", init -> Mono.just(init.initializeResult())))
- .expectError(McpError.class)
+ .expectError(RuntimeException.class)
.verify();
assertThat(initializer.isInitialized()).isFalse();
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java
index 11bd2e4e9..cab847512 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java
@@ -13,7 +13,6 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.MockMcpClientTransport;
-import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
@@ -79,8 +78,9 @@ void testSuccessfulInitialization() {
// Verify initialization result
assertThat(result).isNotNull();
- assertThat(result.protocolVersion()).isEqualTo(transport.protocolVersion());
+ assertThat(result.protocolVersion()).isEqualTo(transport.protocolVersions().get(0));
assertThat(result.capabilities()).isEqualTo(serverCapabilities);
+ assertThat(result.capabilities().logging()).isNull();
assertThat(result.serverInfo()).isEqualTo(serverInfo);
assertThat(result.instructions()).isEqualTo("Test instructions");
@@ -373,7 +373,7 @@ void testSamplingCreateMessageRequestHandlingWithNullHandler() {
// Create client with sampling capability but null handler
assertThatThrownBy(
() -> McpClient.async(transport).capabilities(ClientCapabilities.builder().sampling().build()).build())
- .isInstanceOf(McpError.class)
+ .isInstanceOf(IllegalArgumentException.class)
.hasMessage("Sampling handler must not be null when client capabilities include sampling");
}
@@ -521,7 +521,7 @@ void testElicitationCreateRequestHandlingWithNullHandler() {
// Create client with elicitation capability but null handler
assertThatThrownBy(() -> McpClient.async(transport)
.capabilities(ClientCapabilities.builder().elicitation().build())
- .build()).isInstanceOf(McpError.class)
+ .build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Elicitation handler must not be null when client capabilities include elicitation");
}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
index 7b6777cbe..ae33898b7 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
@@ -1,9 +1,15 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.client;
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.ProtocolVersions;
+
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@@ -20,8 +26,8 @@ class McpAsyncClientTests {
public static final McpSchema.ServerCapabilities MOCK_SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder()
.build();
- public static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult("2024-11-05",
- MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO, "Test instructions");
+ public static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult(
+ ProtocolVersions.MCP_2024_11_05, MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO, "Test instructions");
private static final String CONTEXT_KEY = "context.key";
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java
index 2d41fc55f..3feb1d05c 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java
@@ -37,18 +37,20 @@ void shouldUseLatestVersionByDefault() {
try {
Mono initializeResultMono = client.initialize();
+ String protocolVersion = transport.protocolVersions().get(transport.protocolVersions().size() - 1);
+
StepVerifier.create(initializeResultMono).then(() -> {
McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest();
assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class);
McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params();
- assertThat(initRequest.protocolVersion()).isEqualTo(transport.protocolVersion());
+ assertThat(initRequest.protocolVersion()).isEqualTo(transport.protocolVersions().get(0));
transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
- new McpSchema.InitializeResult(transport.protocolVersion(), null,
+ new McpSchema.InitializeResult(protocolVersion, null,
new McpSchema.Implementation("test-server", "1.0.0"), null),
null));
}).assertNext(result -> {
- assertThat(result.protocolVersion()).isEqualTo(transport.protocolVersion());
+ assertThat(result.protocolVersion()).isEqualTo(protocolVersion);
}).verifyComplete();
}
@@ -111,7 +113,7 @@ void shouldFailForUnsupportedVersion() {
new McpSchema.InitializeResult(unsupportedVersion, null,
new McpSchema.Implementation("test-server", "1.0.0"), null),
null));
- }).expectError(McpError.class).verify();
+ }).expectError(RuntimeException.class).verify();
}
finally {
StepVerifier.create(client.closeGracefully()).verifyComplete();
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java b/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java
new file mode 100644
index 000000000..8b3668671
--- /dev/null
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import com.sun.net.httpserver.HttpServer;
+
+import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.ProtocolVersions;
+import reactor.test.StepVerifier;
+
+/**
+ * Handles emplty application/json response with 200 OK status code.
+ *
+ * @author codezkk
+ */
+public class HttpClientStreamableHttpTransportEmptyJsonResponseTest {
+
+ static int PORT = TomcatTestUtil.findAvailablePort();
+
+ static String host = "http://localhost:" + PORT;
+
+ static HttpServer server;
+
+ @BeforeAll
+ static void startContainer() throws IOException {
+
+ server = HttpServer.create(new InetSocketAddress(PORT), 0);
+
+ // Empty, 200 OK response for the /mcp endpoint
+ server.createContext("/mcp", exchange -> {
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, 0);
+ exchange.close();
+ });
+
+ server.setExecutor(null);
+ server.start();
+ }
+
+ @AfterAll
+ static void stopContainer() {
+ server.stop(1);
+ }
+
+ /**
+ * Regardless of the response (even if the response is null and the content-type is
+ * present), notify should handle it correctly.
+ */
+ @Test
+ @Timeout(3)
+ void testNotificationInitialized() throws URISyntaxException {
+
+ var uri = new URI(host + "/mcp");
+ var mockRequestCustomizer = mock(SyncHttpRequestCustomizer.class);
+ var transport = HttpClientStreamableHttpTransport.builder(host)
+ .httpRequestCustomizer(mockRequestCustomizer)
+ .build();
+
+ var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
+ McpSchema.ClientCapabilities.builder().roots(true).build(),
+ new McpSchema.Implementation("Spring AI MCP Client", "0.3.1"));
+ var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE,
+ "test-id", initializeRequest);
+
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ // Verify the customizer was called
+ verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"));
+
+ }
+
+}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
new file mode 100644
index 000000000..2b502a83b
--- /dev/null
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright 2025-2025 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import com.sun.net.httpserver.HttpServer;
+
+import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import io.modelcontextprotocol.spec.HttpHeaders;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpTransportException;
+import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
+import io.modelcontextprotocol.spec.ProtocolVersions;
+import reactor.test.StepVerifier;
+
+/**
+ * Tests for error handling changes in HttpClientStreamableHttpTransport. Specifically
+ * tests the distinction between session-related errors and general transport errors for
+ * 404 and 400 status codes.
+ *
+ * @author Christian Tzolov
+ */
+@Timeout(15)
+public class HttpClientStreamableHttpTransportErrorHandlingTest {
+
+ private static final int PORT = TomcatTestUtil.findAvailablePort();
+
+ private static final String HOST = "http://localhost:" + PORT;
+
+ private HttpServer server;
+
+ private AtomicReference serverResponseStatus = new AtomicReference<>(200);
+
+ private AtomicReference currentServerSessionId = new AtomicReference<>(null);
+
+ private AtomicReference lastReceivedSessionId = new AtomicReference<>(null);
+
+ private McpClientTransport transport;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(PORT), 0);
+
+ // Configure the /mcp endpoint with dynamic response
+ server.createContext("/mcp", httpExchange -> {
+ if ("DELETE".equals(httpExchange.getRequestMethod())) {
+ httpExchange.sendResponseHeaders(200, 0);
+ }
+ else {
+ // Capture session ID from request if present
+ String requestSessionId = httpExchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
+ lastReceivedSessionId.set(requestSessionId);
+
+ int status = serverResponseStatus.get();
+
+ // Set response headers
+ httpExchange.getResponseHeaders().set("Content-Type", "application/json");
+
+ // Add session ID to response if configured
+ String responseSessionId = currentServerSessionId.get();
+ if (responseSessionId != null) {
+ httpExchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
+ }
+
+ // Send response based on configured status
+ if (status == 200) {
+ String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
+ httpExchange.sendResponseHeaders(200, response.length());
+ httpExchange.getResponseBody().write(response.getBytes());
+ }
+ else {
+ httpExchange.sendResponseHeaders(status, 0);
+ }
+ }
+ httpExchange.close();
+ });
+
+ server.setExecutor(null);
+ server.start();
+
+ transport = HttpClientStreamableHttpTransport.builder(HOST).build();
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ /**
+ * Test that 404 response WITHOUT session ID throws McpTransportException (not
+ * SessionNotFoundException)
+ */
+ @Test
+ void test404WithoutSessionId() {
+ serverResponseStatus.set(404);
+ currentServerSessionId.set(null); // No session ID in response
+
+ var testMessage = createTestRequestMessage();
+
+ StepVerifier.create(transport.sendMessage(testMessage))
+ .expectErrorMatches(throwable -> throwable instanceof McpTransportException
+ && throwable.getMessage().contains("Not Found") && throwable.getMessage().contains("404")
+ && !(throwable instanceof McpTransportSessionNotFoundException))
+ .verify();
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
+ /**
+ * Test that 404 response WITH session ID throws McpTransportSessionNotFoundException
+ */
+ @Test
+ void test404WithSessionId() {
+ // First establish a session
+ serverResponseStatus.set(200);
+ currentServerSessionId.set("test-session-123");
+
+ // Set up exception handler to verify session invalidation
+ @SuppressWarnings("unchecked")
+ Consumer exceptionHandler = mock(Consumer.class);
+ transport.setExceptionHandler(exceptionHandler);
+
+ // Connect with handler
+ StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
+
+ // Send initial message to establish session
+ var testMessage = createTestRequestMessage();
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ // The session should now be established, next request will include session ID
+ // Now return 404 for next request
+ serverResponseStatus.set(404);
+
+ // Send another message - should get SessionNotFoundException
+ StepVerifier.create(transport.sendMessage(testMessage))
+ .expectError(McpTransportSessionNotFoundException.class)
+ .verify();
+
+ // Verify exception handler was called with SessionNotFoundException
+ verify(exceptionHandler).accept(any(McpTransportSessionNotFoundException.class));
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
+ /**
+ * Test that 400 response WITHOUT session ID throws McpTransportException (not
+ * SessionNotFoundException)
+ */
+ @Test
+ void test400WithoutSessionId() {
+ serverResponseStatus.set(400);
+ currentServerSessionId.set(null); // No session ID
+
+ var testMessage = createTestRequestMessage();
+
+ StepVerifier.create(transport.sendMessage(testMessage))
+ .expectErrorMatches(throwable -> throwable instanceof McpTransportException
+ && throwable.getMessage().contains("Bad Request") && throwable.getMessage().contains("400")
+ && !(throwable instanceof McpTransportSessionNotFoundException))
+ .verify();
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
+ /**
+ * Test that 400 response WITH session ID throws McpTransportSessionNotFoundException
+ * This handles the case mentioned in the code comment about some implementations
+ * returning 400 for unknown session IDs.
+ */
+ @Test
+ void test400WithSessionId() {
+ // First establish a session
+ serverResponseStatus.set(200);
+ currentServerSessionId.set("test-session-456");
+
+ // Set up exception handler
+ @SuppressWarnings("unchecked")
+ Consumer exceptionHandler = mock(Consumer.class);
+ transport.setExceptionHandler(exceptionHandler);
+
+ // Connect with handler
+ StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
+
+ // Send initial message to establish session
+ var testMessage = createTestRequestMessage();
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ // The session should now be established, next request will include session ID
+ // Now return 400 for next request (simulating unknown session ID)
+ serverResponseStatus.set(400);
+
+ // Send another message - should get SessionNotFoundException
+ StepVerifier.create(transport.sendMessage(testMessage))
+ .expectError(McpTransportSessionNotFoundException.class)
+ .verify();
+
+ // Verify exception handler was called
+ verify(exceptionHandler).accept(any(McpTransportSessionNotFoundException.class));
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
+ /**
+ * Test session recovery after SessionNotFoundException Verifies that a new session
+ * can be established after the old one is invalidated
+ */
+ @Test
+ void testSessionRecoveryAfter404() {
+ // First establish a session
+ serverResponseStatus.set(200);
+ currentServerSessionId.set("session-1");
+
+ // Send initial message to establish session
+ var testMessage = createTestRequestMessage();
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ assertThat(lastReceivedSessionId.get()).isNull();
+
+ // The session should now be established
+ // Simulate session loss - return 404
+ serverResponseStatus.set(404);
+
+ // This should fail with SessionNotFoundException
+ StepVerifier.create(transport.sendMessage(testMessage))
+ .expectError(McpTransportSessionNotFoundException.class)
+ .verify();
+
+ // Now server is back with new session
+ serverResponseStatus.set(200);
+ currentServerSessionId.set("session-2");
+ lastReceivedSessionId.set(null); // Reset to verify new session
+
+ // Should be able to establish new session
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ // Verify no session ID was sent (since old session was invalidated)
+ assertThat(lastReceivedSessionId.get()).isNull();
+
+ // Next request should use the new session ID
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ // Session ID should now be sent with requests
+ assertThat(lastReceivedSessionId.get()).isEqualTo("session-2");
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
+ /**
+ * Test that reconnect (GET request) also properly handles 404/400 errors
+ */
+ @Test
+ void testReconnectErrorHandling() {
+
+ // Set up SSE endpoint for GET requests
+ server.createContext("/mcp-sse", exchange -> {
+ String method = exchange.getRequestMethod();
+ String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
+
+ if ("GET".equals(method)) {
+ int status = serverResponseStatus.get();
+
+ if (status == 404 && requestSessionId != null) {
+ // 404 with session ID - should trigger SessionNotFoundException
+ exchange.sendResponseHeaders(404, 0);
+ }
+ else if (status == 404) {
+ // 404 without session ID - should trigger McpTransportException
+ exchange.sendResponseHeaders(404, 0);
+ }
+ else {
+ // Normal SSE response
+ exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
+ exchange.sendResponseHeaders(200, 0);
+ // Send a test SSE event
+ String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n";
+ exchange.getResponseBody().write(sseData.getBytes());
+ }
+ }
+ else {
+ // POST request handling
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ String responseSessionId = currentServerSessionId.get();
+ if (responseSessionId != null) {
+ exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
+ }
+ String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
+ exchange.sendResponseHeaders(200, response.length());
+ exchange.getResponseBody().write(response.getBytes());
+ }
+ exchange.close();
+ });
+
+ // Test with session ID - should get SessionNotFoundException
+ serverResponseStatus.set(200);
+ currentServerSessionId.set("sse-session-1");
+
+ var transport = HttpClientStreamableHttpTransport.builder(HOST)
+ .endpoint("/mcp-sse")
+ .openConnectionOnStartup(true) // This will trigger GET request on connect
+ .build();
+
+ // First connect successfully
+ StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
+
+ // Send message to establish session
+ var testMessage = createTestRequestMessage();
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
+
+ // Now simulate server returning 404 on reconnect
+ serverResponseStatus.set(404);
+
+ // This should trigger reconnect which will fail
+ // The error should be handled internally and passed to exception handler
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
+ private McpSchema.JSONRPCRequest createTestRequestMessage() {
+ var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
+ McpSchema.ClientCapabilities.builder().roots(true).build(),
+ new McpSchema.Implementation("Test Client", "1.0.0"));
+ return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id",
+ initializeRequest);
+ }
+
+}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java
index 479468f63..d645bb0b3 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java
@@ -80,7 +80,7 @@ void testRequestCustomizer() throws URISyntaxException {
StepVerifier.create(t.sendMessage(testMessage)).verifyComplete();
// Verify the customizer was called
- verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("GET"), eq(uri), eq(
+ verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq(
"{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"));
});
}
@@ -107,7 +107,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException {
StepVerifier.create(t.sendMessage(testMessage)).verifyComplete();
// Verify the customizer was called
- verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("GET"), eq(uri), eq(
+ verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq(
"{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"));
});
}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java
index 687ff6ae9..acaf0c8a9 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java
@@ -1,12 +1,14 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
+
package io.modelcontextprotocol.server;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertWith;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
@@ -18,10 +20,14 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiFunction;
import java.util.function.Function;
+import java.util.stream.Collectors;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -31,16 +37,22 @@
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
+import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
+import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.ModelPreferences;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
+import io.modelcontextprotocol.spec.McpSchema.PromptReference;
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 io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -131,6 +143,8 @@ void testCreateMessageSuccess(String clientType) {
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
null);
+ AtomicReference samplingResult = new AtomicReference<>();
+
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((exchange, request) -> {
@@ -146,37 +160,35 @@ void testCreateMessageSuccess(String clientType) {
.build())
.build();
- StepVerifier.create(exchange.createMessage(createMessageRequest)).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);
+ return exchange.createMessage(createMessageRequest)
+ .doOnNext(samplingResult::set)
+ .thenReturn(callResponse);
})
.build();
- //@formatter:off
- var mcpServer = prepareAsyncServerBuilder()
- .serverInfo("test-server", "1.0.0")
- .tools(tool)
- .build();
+ var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (
- var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
- .capabilities(ClientCapabilities.builder().sampling().build())
- .sampling(samplingHandler)
- .build()) {//@formatter:on
+ try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
+ .capabilities(ClientCapabilities.builder().sampling().build())
+ .sampling(samplingHandler)
+ .build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
- assertThat(response).isNotNull().isEqualTo(callResponse);
+ assertThat(response).isNotNull();
+ assertThat(response).isEqualTo(callResponse);
+
+ assertWith(samplingResult.get(), 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);
+ });
}
mcpServer.close();
}
@@ -212,6 +224,8 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
null);
+ AtomicReference samplingResult = new AtomicReference<>();
+
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((exchange, request) -> {
@@ -227,16 +241,9 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr
.build())
.build();
- StepVerifier.create(exchange.createMessage(createMessageRequest)).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);
+ return exchange.createMessage(createMessageRequest)
+ .doOnNext(samplingResult::set)
+ .thenReturn(callResponse);
})
.build();
@@ -253,6 +260,15 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
+ assertWith(samplingResult.get(), 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);
+ });
+
mcpClient.close();
mcpServer.close();
}
@@ -299,16 +315,7 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt
.build())
.build();
- StepVerifier.create(exchange.createMessage(createMessageRequest)).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);
+ return exchange.createMessage(createMessageRequest).thenReturn(callResponse);
})
.build();
@@ -322,7 +329,7 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt
assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
- }).withMessageContaining("Timeout");
+ }).withMessageContaining("1000ms");
mcpClient.close();
mcpServer.close();
@@ -339,19 +346,14 @@ void testCreateElicitationWithoutElicitationCapabilities(String clientType) {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
- .callHandler((exchange, request) -> {
-
- exchange.createElicitation(mock(McpSchema.ElicitRequest.class)).block();
-
- return Mono.just(mock(CallToolResult.class));
- })
+ .callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class))
+ .then(Mono.just(mock(CallToolResult.class))))
.build();
var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
- try (
- // Create client without elicitation capabilities
- var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build()) {
+ // Create client without elicitation capabilities
+ try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build()) {
assertThat(client.initialize()).isNotNull();
@@ -427,17 +429,10 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
- Function elicitationHandler = request -> {
+ Function elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
- try {
- TimeUnit.SECONDS.sleep(2);
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
- Map.of("message", request.message()));
+ return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
};
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
@@ -448,6 +443,8 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
null);
+ AtomicReference resultRef = new AtomicReference<>();
+
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
.callHandler((exchange, request) -> {
@@ -458,13 +455,9 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
.build();
- StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
- assertThat(result).isNotNull();
- assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
- assertThat(result.content().get("message")).isEqualTo("Test message");
- }).verifyComplete();
-
- return Mono.just(callResponse);
+ return exchange.createElicitation(elicitationRequest)
+ .doOnNext(resultRef::set)
+ .then(Mono.just(callResponse));
})
.build();
@@ -480,6 +473,11 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
+ assertWith(resultRef.get(), result -> {
+ assertThat(result).isNotNull();
+ assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
+ assertThat(result.content().get("message")).isEqualTo("Test message");
+ });
mcpClient.closeGracefully();
mcpServer.closeGracefully().block();
@@ -736,7 +734,6 @@ void testRootsServerCloseWithActiveSubscription(String clientType) {
// ---------------------------------------
// Tools Tests
// ---------------------------------------
-
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
@@ -751,6 +748,7 @@ void testToolCallSuccess(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
+ var responseBodyIsNullOrBlank = new AtomicBoolean(false);
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
@@ -764,7 +762,7 @@ void testToolCallSuccess(String clientType) {
.GET()
.build(), HttpResponse.BodyHandlers.ofString());
String responseBody = response.body();
- assertThat(responseBody).isNotBlank();
+ responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody));
}
catch (Exception e) {
e.printStackTrace();
@@ -787,6 +785,7 @@ void testToolCallSuccess(String clientType) {
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+ assertThat(responseBodyIsNullOrBlank.get()).isFalse();
assertThat(response).isNotNull().isEqualTo(callResponse);
}
@@ -830,6 +829,62 @@ void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) {
mcpServer.close();
}
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient" })
+ void testToolCallSuccessWithTranportContextExtraction(String clientType) {
+
+ var clientBuilder = clientBuilders.get(clientType);
+
+ var transportContextIsNull = new AtomicBoolean(false);
+ var transportContextIsEmpty = new AtomicBoolean(false);
+ var responseBodyIsNullOrBlank = new AtomicBoolean(false);
+
+ var expectedCallResponse = new McpSchema.CallToolResult(
+ List.of(new McpSchema.TextContent("CALL RESPONSE; ctx=value")), null);
+ McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
+ .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(emptyJsonSchema).build())
+ .callHandler((exchange, request) -> {
+
+ McpTransportContext transportContext = exchange.transportContext();
+ transportContextIsNull.set(transportContext == null);
+ transportContextIsEmpty.set(transportContext.equals(McpTransportContext.EMPTY));
+ String ctxValue = (String) transportContext.get("important");
+
+ try {
+ String responseBody = "TOOL RESPONSE";
+ responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody));
+ }
+ catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ return new McpSchema.CallToolResult(
+ List.of(new McpSchema.TextContent("CALL RESPONSE; ctx=" + ctxValue)), null);
+ })
+ .build();
+
+ var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool1)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
+
+ CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
+
+ assertThat(transportContextIsNull.get()).isFalse();
+ assertThat(transportContextIsEmpty.get()).isFalse();
+ assertThat(responseBodyIsNullOrBlank.get()).isFalse();
+ assertThat(response).isNotNull().isEqualTo(expectedCallResponse);
+ }
+
+ mcpServer.close();
+ }
+
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testToolListChangeHandlingSuccess(String clientType) {
@@ -858,7 +913,7 @@ void testToolListChangeHandlingSuccess(String clientType) {
})
.build();
- AtomicReference> rootsRef = new AtomicReference<>();
+ AtomicReference> toolsRef = new AtomicReference<>();
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
@@ -875,32 +930,31 @@ void testToolListChangeHandlingSuccess(String clientType) {
.build(), HttpResponse.BodyHandlers.ofString());
String responseBody = response.body();
assertThat(responseBody).isNotBlank();
+ toolsRef.set(toolsUpdate);
}
catch (Exception e) {
e.printStackTrace();
}
-
- rootsRef.set(toolsUpdate);
}).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
- assertThat(rootsRef.get()).isNull();
+ assertThat(toolsRef.get()).isNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
mcpServer.notifyToolsListChanged();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
+ assertThat(toolsRef.get()).containsAll(List.of(tool1.tool()));
});
// Remove a tool
mcpServer.removeTool("tool1");
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).isEmpty();
+ assertThat(toolsRef.get()).isEmpty();
});
// Add a new tool
@@ -916,7 +970,7 @@ void testToolListChangeHandlingSuccess(String clientType) {
mcpServer.addTool(tool2);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
+ assertThat(toolsRef.get()).containsAll(List.of(tool2.tool()));
});
}
@@ -940,6 +994,276 @@ void testInitialize(String clientType) {
mcpServer.close();
}
+ // ---------------------------------------
+ // Logging Tests
+ // ---------------------------------------
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient" })
+ void testLoggingNotification(String clientType) throws InterruptedException {
+ int expectedNotificationsCount = 3;
+ CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
+ // Create a list to store received logging notifications
+ List receivedNotifications = new CopyOnWriteArrayList<>();
+
+ var clientBuilder = clientBuilders.get(clientType);
+
+ // Create server with a tool that sends logging notifications
+ McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
+ .tool(Tool.builder()
+ .name("logging-test")
+ .description("Test logging notifications")
+ .inputSchema(emptyJsonSchema)
+ .build())
+ .callHandler((exchange, request) -> {
+
+ // Create and send notifications with different levels
+
+ //@formatter:off
+ return exchange // This should be filtered out (DEBUG < NOTICE)
+ .loggingNotification(McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.DEBUG)
+ .logger("test-logger")
+ .data("Debug message")
+ .build())
+ .then(exchange // This should be sent (NOTICE >= NOTICE)
+ .loggingNotification(McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.NOTICE)
+ .logger("test-logger")
+ .data("Notice message")
+ .build()))
+ .then(exchange // This should be sent (ERROR > NOTICE)
+ .loggingNotification(McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.ERROR)
+ .logger("test-logger")
+ .data("Error message")
+ .build()))
+ .then(exchange // This should be filtered out (INFO < NOTICE)
+ .loggingNotification(McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.INFO)
+ .logger("test-logger")
+ .data("Another info message")
+ .build()))
+ .then(exchange // This should be sent (ERROR >= NOTICE)
+ .loggingNotification(McpSchema.LoggingMessageNotification.builder()
+ .level(McpSchema.LoggingLevel.ERROR)
+ .logger("test-logger")
+ .data("Another error message")
+ .build()))
+ .thenReturn(new CallToolResult("Logging test completed", false));
+ //@formatter:on
+ })
+ .build();
+
+ var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().logging().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (
+ // Create client with logging notification handler
+ var mcpClient = clientBuilder.loggingConsumer(notification -> {
+ receivedNotifications.add(notification);
+ latch.countDown();
+ }).build()) {
+
+ // Initialize client
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Set minimum logging level to NOTICE
+ mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE);
+
+ // Call the tool that sends logging notifications
+ CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of()));
+ assertThat(result).isNotNull();
+ assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+ assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed");
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue();
+
+ // Should have received 3 notifications (1 NOTICE and 2 ERROR)
+ assertThat(receivedNotifications).hasSize(expectedNotificationsCount);
+
+ Map notificationMap = receivedNotifications.stream()
+ .collect(Collectors.toMap(n -> n.data(), n -> n));
+
+ // First notification should be NOTICE level
+ assertThat(notificationMap.get("Notice message").level()).isEqualTo(McpSchema.LoggingLevel.NOTICE);
+ assertThat(notificationMap.get("Notice message").logger()).isEqualTo("test-logger");
+ assertThat(notificationMap.get("Notice message").data()).isEqualTo("Notice message");
+
+ // Second notification should be ERROR level
+ assertThat(notificationMap.get("Error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
+ assertThat(notificationMap.get("Error message").logger()).isEqualTo("test-logger");
+ assertThat(notificationMap.get("Error message").data()).isEqualTo("Error message");
+
+ // Third notification should be ERROR level
+ assertThat(notificationMap.get("Another error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
+ assertThat(notificationMap.get("Another error message").logger()).isEqualTo("test-logger");
+ assertThat(notificationMap.get("Another error message").data()).isEqualTo("Another error message");
+ }
+ mcpServer.close();
+ }
+
+ // ---------------------------------------
+ // Progress Tests
+ // ---------------------------------------
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @ValueSource(strings = { "httpclient" })
+ void testProgressNotification(String clientType) throws InterruptedException {
+ int expectedNotificationsCount = 4; // 3 notifications + 1 for another progress
+ // token
+ CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
+ // Create a list to store received logging notifications
+ List receivedNotifications = new CopyOnWriteArrayList<>();
+
+ var clientBuilder = clientBuilders.get(clientType);
+
+ // Create server with a tool that sends logging notifications
+ McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
+ .tool(McpSchema.Tool.builder()
+ .name("progress-test")
+ .description("Test progress notifications")
+ .inputSchema(emptyJsonSchema)
+ .build())
+ .callHandler((exchange, request) -> {
+
+ // Create and send notifications
+ var progressToken = (String) request.meta().get("progressToken");
+
+ return exchange
+ .progressNotification(
+ new McpSchema.ProgressNotification(progressToken, 0.0, 1.0, "Processing started"))
+ .then(exchange.progressNotification(
+ new McpSchema.ProgressNotification(progressToken, 0.5, 1.0, "Processing data")))
+ .then(// Send a progress notification with another progress value
+ // should
+ exchange.progressNotification(new McpSchema.ProgressNotification("another-progress-token",
+ 0.0, 1.0, "Another processing started")))
+ .then(exchange.progressNotification(
+ new McpSchema.ProgressNotification(progressToken, 1.0, 1.0, "Processing completed")))
+ .thenReturn(new CallToolResult(("Progress test completed"), false));
+ })
+ .build();
+
+ var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(tool)
+ .build();
+
+ try (
+ // Create client with progress notification handler
+ var mcpClient = clientBuilder.progressConsumer(notification -> {
+ receivedNotifications.add(notification);
+ latch.countDown();
+ }).build()) {
+
+ // Initialize client
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ // Call the tool that sends progress notifications
+ McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder()
+ .name("progress-test")
+ .meta(Map.of("progressToken", "test-progress-token"))
+ .build();
+ CallToolResult result = mcpClient.callTool(callToolRequest);
+ assertThat(result).isNotNull();
+ assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
+ assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Progress test completed");
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue();
+
+ // Should have received 3 notifications
+ assertThat(receivedNotifications).hasSize(expectedNotificationsCount);
+
+ Map notificationMap = receivedNotifications.stream()
+ .collect(Collectors.toMap(n -> n.message(), n -> n));
+
+ // First notification should be 0.0/1.0 progress
+ assertThat(notificationMap.get("Processing started").progressToken()).isEqualTo("test-progress-token");
+ assertThat(notificationMap.get("Processing started").progress()).isEqualTo(0.0);
+ assertThat(notificationMap.get("Processing started").total()).isEqualTo(1.0);
+ assertThat(notificationMap.get("Processing started").message()).isEqualTo("Processing started");
+
+ // Second notification should be 0.5/1.0 progress
+ assertThat(notificationMap.get("Processing data").progressToken()).isEqualTo("test-progress-token");
+ assertThat(notificationMap.get("Processing data").progress()).isEqualTo(0.5);
+ assertThat(notificationMap.get("Processing data").total()).isEqualTo(1.0);
+ assertThat(notificationMap.get("Processing data").message()).isEqualTo("Processing data");
+
+ // Third notification should be another progress token with 0.0/1.0 progress
+ assertThat(notificationMap.get("Another processing started").progressToken())
+ .isEqualTo("another-progress-token");
+ assertThat(notificationMap.get("Another processing started").progress()).isEqualTo(0.0);
+ assertThat(notificationMap.get("Another processing started").total()).isEqualTo(1.0);
+ assertThat(notificationMap.get("Another processing started").message())
+ .isEqualTo("Another processing started");
+
+ // Fourth notification should be 1.0/1.0 progress
+ assertThat(notificationMap.get("Processing completed").progressToken()).isEqualTo("test-progress-token");
+ assertThat(notificationMap.get("Processing completed").progress()).isEqualTo(1.0);
+ assertThat(notificationMap.get("Processing completed").total()).isEqualTo(1.0);
+ assertThat(notificationMap.get("Processing completed").message()).isEqualTo("Processing completed");
+ }
+ finally {
+ mcpServer.close();
+ }
+ }
+
+ // ---------------------------------------
+ // Completion Tests
+ // ---------------------------------------
+ @ParameterizedTest(name = "{0} : Completion call")
+ @ValueSource(strings = { "httpclient" })
+ void testCompletionShouldReturnExpectedSuggestions(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ var expectedValues = List.of("python", "pytorch", "pyside");
+ var completionResponse = new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
+ true // hasMore
+ ));
+
+ AtomicReference samplingRequest = new AtomicReference<>();
+ BiFunction completionHandler = (mcpSyncServerExchange,
+ request) -> {
+ samplingRequest.set(request);
+ return completionResponse;
+ };
+
+ var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().completions().build())
+ .prompts(new McpServerFeatures.SyncPromptSpecification(
+ new Prompt("code_review", "Code review", "this is code review prompt",
+ List.of(new PromptArgument("language", "Language", "string", false))),
+ (mcpSyncServerExchange, getPromptRequest) -> null))
+ .completions(new McpServerFeatures.SyncCompletionSpecification(
+ new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler))
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ CompleteRequest request = new CompleteRequest(
+ new PromptReference("ref/prompt", "code_review", "Code review"),
+ new CompleteRequest.CompleteArgument("language", "py"));
+
+ CompleteResult result = mcpClient.completeCompletion(request);
+
+ assertThat(result).isNotNull();
+
+ assertThat(samplingRequest.get().argument().name()).isEqualTo("language");
+ assertThat(samplingRequest.get().argument().value()).isEqualTo("py");
+ assertThat(samplingRequest.get().ref().type()).isEqualTo("ref/prompt");
+ }
+
+ mcpServer.close();
+ }
+
+ // ---------------------------------------
+ // Ping Tests
+ // ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testPingSuccess(String clientType) {
@@ -1002,7 +1326,6 @@ void testPingSuccess(String clientType) {
// ---------------------------------------
// Tool Structured Output Schema Tests
// ---------------------------------------
-
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient" })
void testStructuredOutputValidationSuccess(String clientType) {
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
new file mode 100644
index 000000000..0f2991a9f
--- /dev/null
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 - 2024 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+
+import org.apache.catalina.LifecycleException;
+import org.apache.catalina.LifecycleState;
+import org.apache.catalina.startup.Tomcat;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Timeout;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
+import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
+import io.modelcontextprotocol.server.McpServer.SyncSpecification;
+import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
+import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import jakarta.servlet.http.HttpServletRequest;
+
+@Timeout(15)
+class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
+
+ private static final int PORT = TomcatTestUtil.findAvailablePort();
+
+ private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
+
+ private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
+
+ private HttpServletSseServerTransportProvider mcpServerTransportProvider;
+
+ private Tomcat tomcat;
+
+ @BeforeEach
+ public void before() {
+ // Create and configure the transport provider
+ mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder()
+ .objectMapper(new ObjectMapper())
+ .contextExtractor(TEST_CONTEXT_EXTRACTOR)
+ .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
+ .sseEndpoint(CUSTOM_SSE_ENDPOINT)
+ .build();
+
+ tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
+ try {
+ tomcat.start();
+ assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
+ }
+ catch (Exception e) {
+ throw new RuntimeException("Failed to start Tomcat", e);
+ }
+
+ clientBuilders
+ .put("httpclient",
+ McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
+ .sseEndpoint(CUSTOM_SSE_ENDPOINT)
+ .build()).requestTimeout(Duration.ofHours(10)));
+ }
+
+ @Override
+ protected AsyncSpecification> prepareAsyncServerBuilder() {
+ return McpServer.async(this.mcpServerTransportProvider);
+ }
+
+ @Override
+ protected SyncSpecification> prepareSyncServerBuilder() {
+ return McpServer.sync(this.mcpServerTransportProvider);
+ }
+
+ @AfterEach
+ public void after() {
+ if (mcpServerTransportProvider != null) {
+ mcpServerTransportProvider.closeGracefully().block();
+ }
+ if (tomcat != null) {
+ try {
+ tomcat.stop();
+ tomcat.destroy();
+ }
+ catch (LifecycleException e) {
+ throw new RuntimeException("Failed to stop Tomcat", e);
+ }
+ }
+ }
+
+ @Override
+ protected void prepareClients(int port, String mcpEndpoint) {
+ }
+
+ static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r, tc) -> {
+ tc.put("important", "value");
+ return tc;
+ };
+
+}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
index da8aa4adf..a8951e6dc 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java
@@ -1,35 +1,16 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
-package io.modelcontextprotocol.server;
-
-import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
-import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.awaitility.Awaitility.await;
-import java.time.Duration;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.BiFunction;
-
-import org.apache.catalina.LifecycleException;
-import org.apache.catalina.LifecycleState;
-import org.apache.catalina.startup.Tomcat;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-import org.springframework.web.client.RestClient;
+package io.modelcontextprotocol.server;
import com.fasterxml.jackson.databind.ObjectMapper;
-
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import io.modelcontextprotocol.spec.HttpHeaders;
+import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
@@ -40,8 +21,36 @@
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.spec.ProtocolVersions;
import net.javacrumbs.jsonunit.core.Option;
+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 org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.client.RestClient;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiFunction;
+import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.APPLICATION_JSON;
+import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.TEXT_EVENT_STREAM;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@Timeout(15)
class HttpServletStatelessIntegrationTests {
private static final int PORT = TomcatTestUtil.findAvailablePort();
@@ -459,6 +468,49 @@ void testStructuredOutputRuntimeToolAddition(String clientType) {
mcpServer.close();
}
+ @Test
+ void testThrownMcpError() throws Exception {
+ var mcpServer = McpServer.sync(mcpStatelessServerTransport)
+ .serverInfo("test-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder().tools(true).build())
+ .build();
+
+ Tool testTool = Tool.builder().name("test").description("test").build();
+
+ McpStatelessServerFeatures.SyncToolSpecification toolSpec = new McpStatelessServerFeatures.SyncToolSpecification(
+ testTool, (transportContext, request) -> {
+ throw new McpError(new McpSchema.JSONRPCResponse.JSONRPCError(12345, "testing", Map.of("a", "b")));
+ });
+
+ mcpServer.addTool(toolSpec);
+
+ McpSchema.CallToolRequest callToolRequest = new McpSchema.CallToolRequest("test", Map.of());
+ McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
+ McpSchema.METHOD_TOOLS_CALL, "test", callToolRequest);
+
+ MockHttpServletRequest request = new MockHttpServletRequest("POST", CUSTOM_MESSAGE_ENDPOINT);
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ byte[] content = new ObjectMapper().writeValueAsBytes(jsonrpcRequest);
+ request.setContent(content);
+ request.addHeader("Content-Type", "application/json");
+ request.addHeader("Content-Length", Integer.toString(content.length));
+ request.addHeader("Content-Length", Integer.toString(content.length));
+ request.addHeader("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM);
+ request.addHeader("Content-Type", APPLICATION_JSON);
+ request.addHeader("Cache-Control", "no-cache");
+ request.addHeader(HttpHeaders.PROTOCOL_VERSION, ProtocolVersions.MCP_2025_03_26);
+ mcpStatelessServerTransport.service(request, response);
+
+ McpSchema.JSONRPCResponse jsonrpcResponse = new ObjectMapper().readValue(response.getContentAsByteArray(),
+ McpSchema.JSONRPCResponse.class);
+
+ assertThat(jsonrpcResponse.error())
+ .isEqualTo(new McpSchema.JSONRPCResponse.JSONRPCError(12345, "testing", Map.of("a", "b")));
+
+ mcpServer.close();
+ }
+
private double evaluateExpression(String expression) {
// Simple expression evaluator for testing
return switch (expression) {
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
index ecb0c33c3..2e9b4cbad 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
+
package io.modelcontextprotocol.server;
import static org.assertj.core.api.Assertions.assertThat;
@@ -12,6 +13,7 @@
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Timeout;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -21,7 +23,9 @@
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+import jakarta.servlet.http.HttpServletRequest;
+@Timeout(15)
class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TomcatTestUtil.findAvailablePort();
@@ -37,6 +41,7 @@ public void before() {
// Create and configure the transport provider
mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder()
.objectMapper(new ObjectMapper())
+ .contextExtractor(TEST_CONTEXT_EXTRACTOR)
.mcpEndpoint(MESSAGE_ENDPOINT)
.keepAliveInterval(Duration.ofSeconds(1))
.build();
@@ -54,7 +59,7 @@ public void before() {
.put("httpclient",
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
.endpoint(MESSAGE_ENDPOINT)
- .build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
+ .build()).requestTimeout(Duration.ofHours(10)));
}
@Override
@@ -87,4 +92,9 @@ public void after() {
protected void prepareClients(int port, String mcpEndpoint) {
}
+ static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r, tc) -> {
+ tc.put("important", "value");
+ return tc;
+ };
+
}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java
index e6e80efb0..f915895be 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.server;
import java.util.List;
@@ -23,10 +27,12 @@
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
+import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
import io.modelcontextprotocol.spec.McpSchema.ResourceReference;
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
@@ -80,7 +86,7 @@ public void after() {
tomcat.destroy();
}
catch (LifecycleException e) {
- throw new RuntimeException("Failed to stop Tomcat", e);
+ e.printStackTrace();
}
}
}
@@ -95,8 +101,13 @@ void testCompletionHandlerReceivesContext() {
ResourceReference resourceRef = new ResourceReference("ref/resource", "test://resource/{param}");
- McpSchema.Resource resource = new McpSchema.Resource("test://resource/{param}", "Test Resource",
- "A resource for testing", "text/plain", 123L, null);
+ var resource = Resource.builder()
+ .uri("test://resource/{param}")
+ .name("Test Resource")
+ .description("A resource for testing")
+ .mimeType("text/plain")
+ .size(123L)
+ .build();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().completions().build())
@@ -195,8 +206,13 @@ else if ("products_db".equals(db)) {
return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false));
};
- McpSchema.Resource resource = new McpSchema.Resource("db://{database}/{table}", "Database Table",
- "Resource representing a table in a database", "application/json", 456L, null);
+ McpSchema.Resource resource = Resource.builder()
+ .uri("db://{database}/{table}")
+ .name("Database Table")
+ .description("Resource representing a table in a database")
+ .mimeType("application/json")
+ .size(456L)
+ .build();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().completions().build())
@@ -250,7 +266,10 @@ void testCompletionErrorOnMissingContext() {
// Check if database context is provided
if (request.context() == null || request.context().arguments() == null
|| !request.context().arguments().containsKey("database")) {
- throw new McpError("Please select a database first to see available tables");
+
+ throw McpError.builder(ErrorCodes.INVALID_REQUEST)
+ .message("Please select a database first to see available tables")
+ .build();
}
// Normal completion if context is provided
String db = request.context().arguments().get("database");
@@ -264,8 +283,13 @@ void testCompletionErrorOnMissingContext() {
return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false));
};
- McpSchema.Resource resource = new McpSchema.Resource("db://{database}/{table}", "Database Table",
- "Resource representing a table in a database", "application/json", 456L, null);
+ McpSchema.Resource resource = Resource.builder()
+ .uri("db://{database}/{table}")
+ .name("Database Table")
+ .description("Resource representing a table in a database")
+ .mimeType("application/json")
+ .size(456L)
+ .build();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().completions().build())
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java
index 95086ee81..cdd2bacb7 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java
@@ -45,7 +45,9 @@ void shouldUseLatestVersionByDefault() {
assertThat(jsonResponse.id()).isEqualTo(requestId);
assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class);
McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result();
- assertThat(result.protocolVersion()).isEqualTo(transportProvider.protocolVersion());
+
+ var protocolVersion = transportProvider.protocolVersions().get(transportProvider.protocolVersions().size() - 1);
+ assertThat(result.protocolVersion()).isEqualTo(protocolVersion);
server.closeGracefully().subscribe();
}
@@ -93,7 +95,8 @@ void shouldSuggestLatestVersionForUnsupportedVersion() {
assertThat(jsonResponse.id()).isEqualTo(requestId);
assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class);
McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result();
- assertThat(result.protocolVersion()).isEqualTo(transportProvider.protocolVersion());
+ var protocolVersion = transportProvider.protocolVersions().get(transportProvider.protocolVersions().size() - 1);
+ assertThat(result.protocolVersion()).isEqualTo(protocolVersion);
server.closeGracefully().subscribe();
}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java
index 63d827013..a73ec7209 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java
@@ -24,7 +24,6 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java
index 2cd62889a..0462cbafe 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*/
+
package io.modelcontextprotocol.server.transport;
import com.fasterxml.jackson.databind.ObjectMapper;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java
deleted file mode 100644
index b04ecb3c4..000000000
--- a/mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java
+++ /dev/null
@@ -1,1390 +0,0 @@
-/*
- * Copyright 2024 - 2025 the original author or authors.
- */
-package io.modelcontextprotocol.server.transport;
-
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-import io.modelcontextprotocol.client.McpClient;
-import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
-import io.modelcontextprotocol.server.McpServer;
-import io.modelcontextprotocol.server.McpServerFeatures;
-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.ElicitRequest;
-import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
-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 net.javacrumbs.jsonunit.core.Option;
-
-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.web.client.RestClient;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.assertj.core.api.InstanceOfAssertFactories.type;
-import static org.awaitility.Awaitility.await;
-import static org.mockito.Mockito.mock;
-import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
-import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
-
-class HttpServletSseServerTransportProviderIntegrationTests {
-
- private static final int PORT = TomcatTestUtil.findAvailablePort();
-
- private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
-
- private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
-
- private HttpServletSseServerTransportProvider mcpServerTransportProvider;
-
- McpClient.SyncSpec clientBuilder;
-
- private Tomcat tomcat;
-
- @BeforeEach
- public void before() {
- // Create and configure the transport provider
- mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder()
- .objectMapper(new ObjectMapper())
- .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
- .sseEndpoint(CUSTOM_SSE_ENDPOINT)
- .build();
-
- tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
- try {
- tomcat.start();
- assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
- }
- catch (Exception e) {
- throw new RuntimeException("Failed to start Tomcat", e);
- }
-
- this.clientBuilder = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
- .sseEndpoint(CUSTOM_SSE_ENDPOINT)
- .build());
- }
-
- @AfterEach
- public void after() {
- if (mcpServerTransportProvider != null) {
- mcpServerTransportProvider.closeGracefully().block();
- }
- if (tomcat != null) {
- try {
- tomcat.stop();
- tomcat.destroy();
- }
- catch (LifecycleException e) {
- throw new RuntimeException("Failed to stop Tomcat", e);
- }
- }
- }
-
- // ---------------------------------------
- // Sampling Tests
- // ---------------------------------------
- @Test
- // @Disabled
- void testCreateMessageWithoutSamplingCapabilities() {
-
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)).block();
-
- return Mono.just(mock(CallToolResult.class));
- })
- .build();
-
- var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
-
- try (
- // 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");
- }
- }
- server.close();
- }
-
- @Test
- void testCreateMessageSuccess() {
-
- 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);
- };
-
- CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
- null);
-
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- var createMessageRequest = 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(createMessageRequest)).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);
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .tools(tool)
- .build();
-
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
- .capabilities(ClientCapabilities.builder().sampling().build())
- .sampling(samplingHandler)
- .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);
- }
- mcpServer.close();
- }
-
- @Test
- void testCreateMessageWithRequestTimeoutSuccess() throws InterruptedException {
-
- // Client
-
- Function samplingHandler = request -> {
- assertThat(request.messages()).hasSize(1);
- assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
- try {
- TimeUnit.SECONDS.sleep(2);
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- 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 = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((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);
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .requestTimeout(Duration.ofSeconds(3))
- .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();
- }
-
- @Test
- void testCreateMessageWithRequestTimeoutFail() throws InterruptedException {
-
- // Client
-
- Function samplingHandler = request -> {
- assertThat(request.messages()).hasSize(1);
- assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
- try {
- TimeUnit.SECONDS.sleep(2);
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- 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 = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((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);
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .requestTimeout(Duration.ofSeconds(1))
- .tools(tool)
- .build();
-
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
- mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
- }).withMessageContaining("Timeout");
-
- mcpClient.close();
- mcpServer.close();
- }
-
- // ---------------------------------------
- // Elicitation Tests
- // ---------------------------------------
- @Test
- // @Disabled
- void testCreateElicitationWithoutElicitationCapabilities() {
-
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- exchange.createElicitation(mock(ElicitRequest.class)).block();
-
- return Mono.just(mock(CallToolResult.class));
- })
- .build();
-
- var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
-
- try (
- // Create client without elicitation 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 elicitation capabilities");
- }
- }
- server.closeGracefully().block();
- }
-
- @Test
- void testCreateElicitationSuccess() {
-
- Function elicitationHandler = request -> {
- assertThat(request.message()).isNotEmpty();
- assertThat(request.requestedSchema()).isNotNull();
-
- return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
- };
-
- CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
- null);
-
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- var elicitationRequest = ElicitRequest.builder()
- .message("Test message")
- .requestedSchema(
- Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
- .build();
-
- StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
- assertThat(result).isNotNull();
- assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
- assertThat(result.content().get("message")).isEqualTo("Test message");
- }).verifyComplete();
-
- return Mono.just(callResponse);
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .tools(tool)
- .build();
-
- try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
- .capabilities(ClientCapabilities.builder().elicitation().build())
- .elicitation(elicitationHandler)
- .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);
- }
- mcpServer.closeGracefully().block();
- }
-
- @Test
- void testCreateElicitationWithRequestTimeoutSuccess() {
-
- // Client
-
- Function elicitationHandler = request -> {
- assertThat(request.message()).isNotEmpty();
- assertThat(request.requestedSchema()).isNotNull();
- try {
- TimeUnit.SECONDS.sleep(2);
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
- };
-
- var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
- .capabilities(ClientCapabilities.builder().elicitation().build())
- .elicitation(elicitationHandler)
- .build();
-
- // Server
-
- CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
- null);
-
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- var elicitationRequest = ElicitRequest.builder()
- .message("Test message")
- .requestedSchema(
- Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
- .build();
-
- StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
- assertThat(result).isNotNull();
- assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
- assertThat(result.content().get("message")).isEqualTo("Test message");
- }).verifyComplete();
-
- return Mono.just(callResponse);
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .requestTimeout(Duration.ofSeconds(3))
- .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.closeGracefully();
- mcpServer.closeGracefully().block();
- }
-
- @Test
- void testCreateElicitationWithRequestTimeoutFail() {
-
- // Client
-
- Function elicitationHandler = request -> {
- assertThat(request.message()).isNotEmpty();
- assertThat(request.requestedSchema()).isNotNull();
- try {
- TimeUnit.SECONDS.sleep(2);
- }
- catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
- };
-
- var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
- .capabilities(ClientCapabilities.builder().elicitation().build())
- .elicitation(elicitationHandler)
- .build();
-
- // Server
-
- CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
- null);
-
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- var elicitationRequest = ElicitRequest.builder()
- .message("Test message")
- .requestedSchema(
- Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
- .build();
-
- StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
- assertThat(result).isNotNull();
- assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
- assertThat(result.content().get("message")).isEqualTo("Test message");
- }).verifyComplete();
-
- return Mono.just(callResponse);
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .requestTimeout(Duration.ofSeconds(1))
- .tools(tool)
- .build();
-
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
- mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
- }).withMessageContaining("Timeout");
-
- mcpClient.closeGracefully();
- mcpServer.closeGracefully().block();
- }
-
- // ---------------------------------------
- // 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();
-
- try (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));
- });
-
- mcpServer.close();
- }
- }
-
- @Test
- void testRootsWithoutCapability() {
-
- McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- exchange.listRoots(); // try to list roots
-
- return mock(CallToolResult.class);
- })
- .build();
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> {
- }).tools(tool).build();
-
- try (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");
- }
- }
-
- mcpServer.close();
- }
-
- @Test
- void testRootsNotificationWithEmptyRootsList() {
- AtomicReference> rootsRef = new AtomicReference<>();
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
- .build();
-
- try (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();
- });
- }
-
- mcpServer.close();
- }
-
- @Test
- void testRootsWithMultipleHandlers() {
- List roots = List.of(new Root("uri1://", "root1"));
-
- AtomicReference> rootsRef1 = new AtomicReference<>();
- AtomicReference