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..e2adb340c 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,13 @@
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.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,12 +36,17 @@
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;
@@ -131,6 +141,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 +158,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 +222,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 +239,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 +258,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 +313,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 +327,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 +344,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 +427,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 +441,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 +453,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 +471,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 +732,6 @@ void testRootsServerCloseWithActiveSubscription(String clientType) {
// ---------------------------------------
// Tools Tests
// ---------------------------------------
-
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
@@ -858,7 +853,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 +870,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 +910,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 +934,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 +1266,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..823c28d8e
--- /dev/null
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java
@@ -0,0 +1,95 @@
+/*
+ * 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;
+
+@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())
+ .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) {
+ }
+
+}
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..8a8675d95 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;
@@ -22,6 +24,7 @@
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
+@Timeout(15)
class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
private static final int PORT = TomcatTestUtil.findAvailablePort();
@@ -54,7 +57,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
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> rootsRef2 = new AtomicReference<>();
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate))
- .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
- .build();
-
- try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
- .roots(roots)
- .build()) {
-
- assertThat(mcpClient.initialize()).isNotNull();
-
- mcpClient.rootsListChangedNotification();
-
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef1.get()).containsAll(roots);
- assertThat(rootsRef2.get()).containsAll(roots);
- });
- }
-
- mcpServer.close();
- }
-
- @Test
- void testRootsServerCloseWithActiveSubscription() {
- List roots = List.of(new Root("uri1://", "root1"));
-
- AtomicReference> rootsRef = new AtomicReference<>();
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
- .build();
-
- try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
- .roots(roots)
- .build()) {
-
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- mcpClient.rootsListChangedNotification();
-
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).containsAll(roots);
- });
- }
-
- mcpServer.close();
- }
-
- // ---------------------------------------
- // Tools Tests
- // ---------------------------------------
-
- String emptyJsonSchema = """
- {
- "$schema": "http://json-schema.org/draft-07/schema#",
- "type": "object",
- "properties": {}
- }
- """;
-
- @Test
- void testToolCallSuccess() {
-
- var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
-
- McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
- assertThat(McpTestServletFilter.getThreadLocalValue()).as("blocking code exectuion should be offloaded")
- .isNull();
- // perform a blocking call to a remote service
- String response = RestClient.create()
- .get()
- .uri("https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")
- .retrieve()
- .body(String.class);
- assertThat(response).isNotBlank();
- return callResponse;
- })
- .build();
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool1)
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
-
- CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
-
- assertThat(response).isNotNull();
- assertThat(response).isEqualTo(callResponse);
- }
-
- mcpServer.close();
- }
-
- @Test
- void testToolCallImmediateExecution() {
- McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(
- new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
- var threadLocalValue = McpTestServletFilter.getThreadLocalValue();
- return CallToolResult.builder()
- .addTextContent(threadLocalValue != null ? threadLocalValue : "")
- .build();
- });
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool1)
- .immediateExecution(true)
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
- mcpClient.initialize();
-
- CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
-
- assertThat(response).isNotNull();
- assertThat(response.content()).first()
- .asInstanceOf(type(McpSchema.TextContent.class))
- .extracting(McpSchema.TextContent::text)
- .isEqualTo(McpTestServletFilter.THREAD_LOCAL_VALUE);
- }
-
- mcpServer.close();
- }
-
- @Test
- void testToolListChangeHandlingSuccess() {
-
- var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
- McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema))
- .callHandler((exchange, request) -> {
- // perform a blocking call to a remote service
- String response = RestClient.create()
- .get()
- .uri("https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")
- .retrieve()
- .body(String.class);
- assertThat(response).isNotBlank();
- return callResponse;
- })
- .build();
-
- AtomicReference> rootsRef = new AtomicReference<>();
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool1)
- .build();
-
- try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
- // perform a blocking call to a remote service
- String response = RestClient.create()
- .get()
- .uri("https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")
- .retrieve()
- .body(String.class);
- assertThat(response).isNotBlank();
- rootsRef.set(toolsUpdate);
- }).build()) {
-
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- assertThat(rootsRef.get()).isNull();
-
- assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
-
- mcpServer.notifyToolsListChanged();
-
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
- });
-
- // Remove a tool
- mcpServer.removeTool("tool1");
-
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).isEmpty();
- });
-
- // Add a new tool
- McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder()
- .tool(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema))
- .callHandler((exchange, request) -> callResponse)
- .build();
-
- mcpServer.addTool(tool2);
-
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
- });
- }
-
- mcpServer.close();
- }
-
- @Test
- void testInitialize() {
- var mcpServer = McpServer.sync(mcpServerTransportProvider).build();
-
- try (var mcpClient = clientBuilder.build()) {
-
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
- }
-
- mcpServer.close();
- }
-
- // ---------------------------------------
- // Logging Tests
- // ---------------------------------------
- @Test
- void testLoggingNotification() {
- // Create a list to store received logging notifications
- List receivedNotifications = new CopyOnWriteArrayList<>();
-
- // Create server with a tool that sends logging notifications
- McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
- .tool(new McpSchema.Tool("logging-test", "Test logging notifications", emptyJsonSchema))
- .callHandler((exchange, request) -> {
-
- // Create and send notifications with different levels
-
- // This should be filtered out (DEBUG < NOTICE)
- exchange
- .loggingNotification(McpSchema.LoggingMessageNotification.builder()
- .level(McpSchema.LoggingLevel.DEBUG)
- .logger("test-logger")
- .data("Debug message")
- .build())
- .block();
-
- // This should be sent (NOTICE >= NOTICE)
- exchange
- .loggingNotification(McpSchema.LoggingMessageNotification.builder()
- .level(McpSchema.LoggingLevel.NOTICE)
- .logger("test-logger")
- .data("Notice message")
- .build())
- .block();
-
- // This should be sent (ERROR > NOTICE)
- exchange
- .loggingNotification(McpSchema.LoggingMessageNotification.builder()
- .level(McpSchema.LoggingLevel.ERROR)
- .logger("test-logger")
- .data("Error message")
- .build())
- .block();
-
- // This should be filtered out (INFO < NOTICE)
- exchange
- .loggingNotification(McpSchema.LoggingMessageNotification.builder()
- .level(McpSchema.LoggingLevel.INFO)
- .logger("test-logger")
- .data("Another info message")
- .build())
- .block();
-
- // This should be sent (ERROR >= NOTICE)
- exchange
- .loggingNotification(McpSchema.LoggingMessageNotification.builder()
- .level(McpSchema.LoggingLevel.ERROR)
- .logger("test-logger")
- .data("Another error message")
- .build())
- .block();
-
- return Mono.just(new CallToolResult("Logging test completed", false));
- })
- .build();
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .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);
- }).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");
-
- // Wait for notifications to be processed
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
-
- System.out.println("Received notifications: " + receivedNotifications);
-
- // Should have received 3 notifications (1 NOTICE and 2 ERROR)
- assertThat(receivedNotifications).hasSize(3);
-
- 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
- // ---------------------------------------
- @Test
- void testProgressNotification() {
- // Create a list to store received progress notifications
- List receivedNotifications = new CopyOnWriteArrayList<>();
-
- // Create server with a tool that sends progress notifications
- McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
- McpSchema.Tool.builder()
- .name("progress-test")
- .description("Test progress notifications")
- .inputSchema(emptyJsonSchema)
- .build(),
- null, (exchange, request) -> {
-
- var progressToken = request.progressToken();
-
- exchange
- .progressNotification(
- new McpSchema.ProgressNotification(progressToken, 0.1, 1.0, "Test progress 1/10"))
- .block();
-
- exchange
- .progressNotification(
- new McpSchema.ProgressNotification(progressToken, 0.5, 1.0, "Test progress 5/10"))
- .block();
-
- exchange
- .progressNotification(
- new McpSchema.ProgressNotification(progressToken, 1.0, 1.0, "Test progress 10/10"))
- .block();
-
- return Mono.just(new CallToolResult("Progress test completed", false));
- });
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .capabilities(ServerCapabilities.builder().logging().tools(true).build())
- .tools(tool)
- .build();
-
- // Create client with progress notification handler
- try (var mcpClient = clientBuilder.progressConsumer(receivedNotifications::add).build()) {
-
- // Initialize client
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- // Call the tool that sends progress notifications
- CallToolResult result = mcpClient.callTool(
- new McpSchema.CallToolRequest("progress-test", Map.of(), Map.of("progressToken", "test-token")));
- assertThat(result).isNotNull();
- assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
- assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Progress test completed");
-
- // Wait for notifications to be processed
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- // Should have received 3 notifications
- assertThat(receivedNotifications).hasSize(3);
-
- // Check the progress notifications
- assertThat(receivedNotifications.stream().map(McpSchema.ProgressNotification::progressToken))
- .containsExactlyInAnyOrder("test-token", "test-token", "test-token");
- assertThat(receivedNotifications.stream().map(McpSchema.ProgressNotification::progress))
- .containsExactlyInAnyOrder(0.1, 0.5, 1.0);
- });
- }
- finally {
- mcpServer.close();
- }
- }
-
- // ---------------------------------------
- // Ping Tests
- // ---------------------------------------
- @Test
- void testPingSuccess() {
- // Create server with a tool that uses ping functionality
- AtomicReference executionOrder = new AtomicReference<>("");
-
- McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
- new McpSchema.Tool("ping-async-test", "Test ping async behavior", emptyJsonSchema),
- (exchange, request) -> {
-
- executionOrder.set(executionOrder.get() + "1");
-
- // Test async ping behavior
- return exchange.ping().doOnNext(result -> {
-
- assertThat(result).isNotNull();
- // Ping should return an empty object or map
- assertThat(result).isInstanceOf(Map.class);
-
- executionOrder.set(executionOrder.get() + "2");
- assertThat(result).isNotNull();
- }).then(Mono.fromCallable(() -> {
- executionOrder.set(executionOrder.get() + "3");
- return new CallToolResult("Async ping test completed", false);
- }));
- });
-
- var mcpServer = McpServer.async(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool)
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
-
- // Initialize client
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- // Call the tool that tests ping async behavior
- CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("ping-async-test", Map.of()));
- assertThat(result).isNotNull();
- assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
- assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Async ping test completed");
-
- // Verify execution order
- assertThat(executionOrder.get()).isEqualTo("123");
- }
-
- mcpServer.close();
- }
-
- // ---------------------------------------
- // Tool Structured Output Schema Tests
- // ---------------------------------------
- @Test
- void testStructuredOutputValidationSuccess() {
- // Create a tool with output schema
- Map outputSchema = Map.of(
- "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
- Map.of("type", "string"), "timestamp", Map.of("type", "string")),
- "required", List.of("result", "operation"));
-
- Tool calculatorTool = Tool.builder()
- .name("calculator")
- .description("Performs mathematical calculations")
- .outputSchema(outputSchema)
- .build();
-
- McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
- (exchange, request) -> {
- String expression = (String) request.getOrDefault("expression", "2 + 3");
- double result = evaluateExpression(expression);
- return CallToolResult.builder()
- .structuredContent(
- Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
- .build();
- });
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool)
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- // Verify tool is listed with output schema
- var toolsList = mcpClient.listTools();
- assertThat(toolsList.tools()).hasSize(1);
- assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
- // Note: outputSchema might be null in sync server, but validation still works
-
- // Call tool with valid structured output
- CallToolResult response = mcpClient
- .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
-
- assertThat(response).isNotNull();
- assertThat(response.isError()).isFalse();
- assertThat(response.content()).hasSize(1);
- assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
-
- assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER)
- .when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
- .isObject()
- .isEqualTo(json("""
- {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
-
- // Verify structured content (may be null in sync server but validation still
- // works)
- if (response.structuredContent() != null) {
- assertThat(response.structuredContent()).containsEntry("result", 5.0)
- .containsEntry("operation", "2 + 3")
- .containsEntry("timestamp", "2024-01-01T10:00:00Z");
- }
- }
-
- mcpServer.close();
- }
-
- @Test
- void testStructuredOutputValidationFailure() {
-
- // Create a tool with output schema
- Map outputSchema = Map.of("type", "object", "properties",
- Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
- List.of("result", "operation"));
-
- Tool calculatorTool = Tool.builder()
- .name("calculator")
- .description("Performs mathematical calculations")
- .outputSchema(outputSchema)
- .build();
-
- McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
- (exchange, request) -> {
- // Return invalid structured output. Result should be number, missing
- // operation
- return CallToolResult.builder()
- .addTextContent("Invalid calculation")
- .structuredContent(Map.of("result", "not-a-number", "extra", "field"))
- .build();
- });
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool)
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- // Call tool with invalid structured output
- CallToolResult response = mcpClient
- .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
-
- assertThat(response).isNotNull();
- assertThat(response.isError()).isTrue();
- assertThat(response.content()).hasSize(1);
- assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
-
- String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
- assertThat(errorMessage).contains("Validation failed");
- }
-
- mcpServer.close();
- }
-
- @Test
- void testStructuredOutputMissingStructuredContent() {
- // Create a tool with output schema
- Map outputSchema = Map.of("type", "object", "properties",
- Map.of("result", Map.of("type", "number")), "required", List.of("result"));
-
- Tool calculatorTool = Tool.builder()
- .name("calculator")
- .description("Performs mathematical calculations")
- .outputSchema(outputSchema)
- .build();
-
- McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification(calculatorTool,
- (exchange, request) -> {
- // Return result without structured content but tool has output schema
- return CallToolResult.builder().addTextContent("Calculation completed").build();
- });
-
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .tools(tool)
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- // Call tool that should return structured content but doesn't
- CallToolResult response = mcpClient
- .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
-
- assertThat(response).isNotNull();
- assertThat(response.isError()).isTrue();
- assertThat(response.content()).hasSize(1);
- assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
-
- String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
- assertThat(errorMessage).isEqualTo(
- "Response missing structured content which is expected when calling tool with non-empty outputSchema");
- }
-
- mcpServer.close();
- }
-
- @Test
- void testStructuredOutputRuntimeToolAddition() {
- // Start server without tools
- var mcpServer = McpServer.sync(mcpServerTransportProvider)
- .serverInfo("test-server", "1.0.0")
- .capabilities(ServerCapabilities.builder().tools(true).build())
- .build();
-
- try (var mcpClient = clientBuilder.build()) {
- InitializeResult initResult = mcpClient.initialize();
- assertThat(initResult).isNotNull();
-
- // Initially no tools
- assertThat(mcpClient.listTools().tools()).isEmpty();
-
- // Add tool with output schema at runtime
- Map outputSchema = Map.of("type", "object", "properties",
- Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
- List.of("message", "count"));
-
- Tool dynamicTool = Tool.builder()
- .name("dynamic-tool")
- .description("Dynamically added tool")
- .outputSchema(outputSchema)
- .build();
-
- McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(dynamicTool,
- (exchange, request) -> {
- int count = (Integer) request.getOrDefault("count", 1);
- return CallToolResult.builder()
- .addTextContent("Dynamic tool executed " + count + " times")
- .structuredContent(Map.of("message", "Dynamic execution", "count", count))
- .build();
- });
-
- // Add tool to server
- mcpServer.addTool(toolSpec);
-
- // Wait for tool list change notification
- await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
- assertThat(mcpClient.listTools().tools()).hasSize(1);
- });
-
- // Verify tool was added with output schema
- var toolsList = mcpClient.listTools();
- assertThat(toolsList.tools()).hasSize(1);
- assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
- // Note: outputSchema might be null in sync server, but validation still works
-
- // Call dynamically added tool
- CallToolResult response = mcpClient
- .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
-
- assertThat(response).isNotNull();
- assertThat(response.isError()).isFalse();
- assertThat(response.structuredContent()).containsEntry("message", "Dynamic execution")
- .containsEntry("count", 3);
- }
-
- mcpServer.close();
- }
-
- private double evaluateExpression(String expression) {
- // Simple expression evaluator for testing
- return switch (expression) {
- case "2 + 3" -> 5.0;
- case "10 * 2" -> 20.0;
- case "7 + 8" -> 15.0;
- case "5 + 3" -> 8.0;
- default -> 0.0;
- };
- }
-
-}
diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java b/mcp/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java
index 5a3928e02..2cf95dc94 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java
@@ -1,6 +1,7 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
+
package io.modelcontextprotocol.server.transport;
import java.io.IOException;
diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/ArgumentException.java b/mcp/src/test/java/io/modelcontextprotocol/spec/ArgumentException.java
index ba4e851f9..a0bd568ef 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/spec/ArgumentException.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/spec/ArgumentException.java
@@ -1,3 +1,7 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ */
+
package io.modelcontextprotocol.spec;
public class ArgumentException {
diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java
index 9da31b38b..30158543d 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/spec/DefaultJsonSchemaValidatorTests.java
@@ -1,6 +1,7 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
+
package io.modelcontextprotocol.spec;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -26,7 +27,6 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.modelcontextprotocol.spec.DefaultJsonSchemaValidator;
import io.modelcontextprotocol.spec.JsonSchemaValidator.ValidationResponse;
/**
diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
index fbbb4307e..a5b2137fd 100644
--- a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
+++ b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
@@ -1,6 +1,7 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
+
package io.modelcontextprotocol.spec;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
@@ -319,8 +320,8 @@ void testInitializeRequest() throws Exception {
McpSchema.Implementation clientInfo = new McpSchema.Implementation("test-client", "1.0.0");
Map meta = Map.of("metaKey", "metaValue");
- McpSchema.InitializeRequest request = new McpSchema.InitializeRequest("2024-11-05", capabilities, clientInfo,
- meta);
+ McpSchema.InitializeRequest request = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2024_11_05,
+ capabilities, clientInfo, meta);
String value = mapper.writeValueAsString(request);
assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER)
@@ -342,8 +343,8 @@ void testInitializeResult() throws Exception {
McpSchema.Implementation serverInfo = new McpSchema.Implementation("test-server", "1.0.0");
- McpSchema.InitializeResult result = new McpSchema.InitializeResult("2024-11-05", capabilities, serverInfo,
- "Server initialized successfully");
+ McpSchema.InitializeResult result = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05,
+ capabilities, serverInfo, "Server initialized successfully");
String value = mapper.writeValueAsString(result);
assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER)
diff --git a/pom.xml b/pom.xml
index b7a66aeec..c0b1f7a44 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 0.11.0-SNAPSHOT
+ 0.12.0-SNAPSHOT
pom
https://github.com/modelcontextprotocol/java-sdk