() {
diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpClient.java
index a9b968b..847849e 100644
--- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpClient.java
+++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpClient.java
@@ -17,7 +17,11 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.ai.mcp.util.Assert;
@@ -38,10 +42,12 @@ import org.springframework.ai.mcp.util.Assert;
* including request timeout and JSON object mapping.
*
*
- * Use the builder pattern for flexible client configuration:
{@code
+ * Use the builder pattern for flexible client configuration:
+ *
+ * {@code
* McpClient.using(transport)
- * .withRequestTimeout(Duration.ofSeconds(5))
- * .sync(); // or .async()
+ * .withRequestTimeout(Duration.ofSeconds(5))
+ * .sync(); // or .async()
* }
*
* @author Christian Tzolov
@@ -74,6 +80,10 @@ public class McpClient {
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
+ private boolean rootsListChangedNotification = false;
+
+ private List>> rootsListProviders = new ArrayList<>();
+
private Builder(McpTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
@@ -84,12 +94,22 @@ public class McpClient {
* @param requestTimeout The duration to wait before timing out requests
* @return This builder instance
*/
- public Builder withRequestTimeout(Duration requestTimeout) {
+ public Builder requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
+ public Builder rootsListChangedNotification(boolean rootsListChangedNotification) {
+ this.rootsListChangedNotification = rootsListChangedNotification;
+ return this;
+ }
+
+ public Builder rootsListProvider(Supplier> rootsListProvider) {
+ this.rootsListProviders.add(rootsListProvider);
+ return this;
+ }
+
/**
* Build a synchronous MCP client.
* @return A new instance of {@link McpSyncClient}
@@ -103,7 +123,7 @@ public class McpClient {
* @return A new instance of {@link McpAsyncClient}
*/
public McpAsyncClient async() {
- return new McpAsyncClient(transport, requestTimeout);
+ return new McpAsyncClient(transport, requestTimeout, rootsListProviders, rootsListChangedNotification);
}
}
@@ -121,7 +141,7 @@ public class McpClient {
*/
@Deprecated
public static McpAsyncClient async(McpTransport transport, Duration requestTimeout) {
- return using(transport).withRequestTimeout(requestTimeout).async();
+ return using(transport).requestTimeout(requestTimeout).async();
}
/**
@@ -137,7 +157,7 @@ public class McpClient {
*/
@Deprecated
public static McpSyncClient sync(McpTransport transport, Duration requestTimeout) {
- return using(transport).withRequestTimeout(requestTimeout).sync();
+ return using(transport).requestTimeout(requestTimeout).sync();
}
}
diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java
index cb82f54..71786a6 100644
--- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java
+++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java
@@ -108,15 +108,6 @@ public class DefaultMcpSession implements McpSession {
}
- /**
- * Creates a new DefaultMcpSession with the specified configuration.
- * @param requestTimeout Duration to wait for responses
- * @param transport Transport implementation for message exchange
- */
- public DefaultMcpSession(Duration requestTimeout, McpTransport transport) {
- this(requestTimeout, transport, Map.of(), Map.of());
- }
-
/**
* Creates a new DefaultMcpSession with the specified configuration and handlers.
* @param requestTimeout Duration to wait for responses
diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
index 1415d9f..86cf60e 100644
--- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
+++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
@@ -155,16 +155,53 @@ public class McpSchema {
@JsonProperty("instructions") String instructions) {
} // @formatter:on
+ /**
+ * Clients can implement additional features to enrich connected MCP servers with
+ * additional capabilities. These capabilities can be used to extend the functionality
+ * of the server, or to provide additional information to the server about the
+ * client's capabilities.
+ *
+ * @param experimental WIP
+ * @param roots define the boundaries of where servers can operate within the
+ * filesystem, allowing them to understand which directories and files they have
+ * access to.
+ * @param sampling Provides a standardized way for servers to request LLM sampling
+ * (“completions” or “generations”) from language models via clients.
+ *
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ClientCapabilities( // @formatter:off
@JsonProperty("experimental") Map experimental,
@JsonProperty("roots") RootCapabilities roots,
- @JsonProperty("sampling") Object sampling) {
+ @JsonProperty("sampling") Sampling sampling) {
+ /**
+ * Roots define the boundaries of where servers can operate within the filesystem,
+ * allowing them to understand which directories and files they have access to.
+ * Servers can request the list of roots from supporting clients and
+ * receive notifications when that list changes.
+ *
+ * @param listChanged Whether the client would send notification about roots
+ * has changed since the last time the server checked.
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record RootCapabilities(
@JsonProperty("listChanged") Boolean listChanged) {
}
+
+ /**
+ * Provides a standardized way for servers to request LLM
+ * sampling (“completions” or “generations”) from language
+ * models via clients. This flow allows clients to maintain
+ * control over model access, selection, and permissions
+ * while enabling servers to leverage AI capabilities—with
+ * no server API keys necessary. Servers can request text or
+ * image-based interactions and optionally include context
+ * from MCP servers in their prompts.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ public record Sampling() {
+ }
}// @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java
index 12c3d71..9ab8205 100644
--- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java
@@ -39,7 +39,7 @@ public class ClientSessionTests2 {
.build();
try (McpSyncClient clientSession = McpClient.using(new StdioClientTransport(stdioParams))
- .withRequestTimeout(Duration.ofSeconds(10))
+ .requestTimeout(Duration.ofSeconds(10))
.sync()) {
clientSession.initialize();
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java
index de1417c..474a961 100644
--- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java
@@ -42,7 +42,7 @@ public class ClientSessionTests3 {
try {
clientSession = McpClient.using(new StdioClientTransport(stdioParams))
- .withRequestTimeout(Duration.ofSeconds(10))
+ .requestTimeout(Duration.ofSeconds(10))
.sync();
clientSession.initialize();
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java
index 1e96dad..e7a6f8d 100644
--- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java
@@ -40,7 +40,7 @@ public class MainSSE {
try (McpSyncClient mcpClient = McpClient
.using(new SseClientTransport(WebClient.builder().baseUrl("http://localhost:3001")))
- .withRequestTimeout(Duration.ofSeconds(1000))
+ .requestTimeout(Duration.ofSeconds(1000))
.sync()) {
mcpClient.initialize();
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
index 08cf29a..eba0302 100644
--- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
@@ -17,7 +17,9 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
+import java.util.List;
import java.util.Map;
+import java.util.function.Supplier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -28,6 +30,7 @@ import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
import org.springframework.ai.mcp.spec.McpSchema.Prompt;
import org.springframework.ai.mcp.spec.McpSchema.Resource;
+import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpSchema.Tool;
import org.springframework.ai.mcp.spec.McpTransport;
@@ -64,7 +67,7 @@ public abstract class AbstractMcpAsyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
- mcpAsyncClient = McpClient.using(mcpTransport).withRequestTimeout(TIMEOUT).async();
+ mcpAsyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).async();
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
}).doesNotThrowAnyException();
}
@@ -83,7 +86,7 @@ public abstract class AbstractMcpAsyncClientTests {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
- assertThatThrownBy(() -> McpClient.using(mcpTransport).withRequestTimeout(null).sync())
+ assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
@@ -123,11 +126,6 @@ public abstract class AbstractMcpAsyncClientTests {
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
}
- @Test
- void testRootsListChanged() {
- assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException();
- }
-
@Test
void testListResources() {
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
@@ -175,4 +173,21 @@ public abstract class AbstractMcpAsyncClientTests {
.verifyComplete();
}
+ @Test
+ void testRootsListChanged() {
+ assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testInitializeWithRootsListProviders() {
+ var transport = createMcpTransport();
+ List>> providers = List.of(() -> List.of(new Root("file:///test/path", "test-root")));
+
+ var client = new McpAsyncClient(transport, TIMEOUT, providers, true);
+
+ assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+
+ assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
+ }
+
}
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
index 00dc68d..eedeb00 100644
--- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
@@ -64,7 +64,7 @@ public abstract class AbstractMcpSyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
- mcpSyncClient = McpClient.using(mcpTransport).withRequestTimeout(TIMEOUT).sync();
+ mcpSyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).sync();
mcpSyncClient.initialize();
}).doesNotThrowAnyException();
}
@@ -82,7 +82,7 @@ public abstract class AbstractMcpSyncClientTests {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
- assertThatThrownBy(() -> McpClient.using(mcpTransport).withRequestTimeout(null).sync())
+ assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
new file mode 100644
index 0000000..c72adcd
--- /dev/null
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2024-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.mcp.client;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.core.scheduler.Schedulers;
+
+import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification;
+import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest;
+import org.springframework.ai.mcp.spec.McpSchema.Root;
+import org.springframework.ai.mcp.spec.McpTransport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class McpAsyncClientResponseHandlerTests {
+
+ private McpAsyncClient asyncMcpClient;
+
+ private MockMcpTransport transport;
+
+ @SuppressWarnings("unused")
+ private static class MockMcpTransport implements McpTransport {
+
+ private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
+
+ private Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer();
+
+ private Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer();
+
+ private Flux outboundView = outgoing.asFlux().cache(1);
+
+ public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
+ if (inbound.tryEmitNext(message).isFailure()) {
+ throw new RuntimeException("Failed to emit message " + message);
+ }
+ inboundMessageCount.incrementAndGet();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (outgoing.tryEmitNext(message).isFailure()) {
+ return Mono.error(new RuntimeException("Can't emit outgoing message " + message));
+ }
+ return Mono.empty();
+ }
+
+ public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
+ return (JSONRPCRequest) outboundView.blockFirst();
+ }
+
+ public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() {
+ return (JSONRPCNotification) outboundView.blockFirst();
+ }
+
+ public McpSchema.JSONRPCMessage getLastSentMessage() {
+ return outboundView.blockFirst();
+ }
+
+ @Override
+ public Mono connect(Function, Mono> handler) {
+ return inbound.asFlux()
+ .publishOn(Schedulers.boundedElastic())
+ .flatMap(message -> Mono.just(message).transform(handler))
+ .then();
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeReference typeRef) {
+ return new ObjectMapper().convertValue(data, typeRef);
+ }
+
+ }
+
+ @BeforeEach
+ void setUp() {
+ transport = new MockMcpTransport();
+
+ Supplier> rootsListProvider = () -> List.of(new Root("file:///test/path", "test-root"));
+
+ asyncMcpClient = McpClient.using(transport).rootsListProvider(rootsListProvider).async();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (asyncMcpClient != null) {
+ asyncMcpClient.closeGracefully();
+ }
+ }
+
+ @Test
+ void testRootsListRequestHandling() {
+
+ // Simulate incoming request
+ McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "roots/list",
+ "test-id", null);
+ transport.simulateIncomingMessage(request);
+
+ // Verify response
+ McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage();
+ assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class);
+
+ McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage;
+ assertThat(response.id()).isEqualTo("test-id");
+ assertThat(response.result()).isEqualTo(List.of(new Root("file:///test/path", "test-root")));
+ assertThat(response.error()).isNull();
+ }
+
+}
diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java
index 75e82a3..a29e0d2 100644
--- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java
+++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java
@@ -62,8 +62,6 @@ class DefaultMcpSessionTests {
private MockMcpTransport transport;
- private ObjectMapper objectMapper;
-
@SuppressWarnings("unused")
private static class MockMcpTransport implements McpTransport {
@@ -124,7 +122,6 @@ class DefaultMcpSessionTests {
@BeforeEach
void setUp() {
- objectMapper = new ObjectMapper();
transport = new MockMcpTransport();
session = new DefaultMcpSession(TIMEOUT, transport, Map.of(),
Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: " + params))));
@@ -139,10 +136,12 @@ class DefaultMcpSessionTests {
@Test
void testConstructorWithInvalidArguments() {
- assertThatThrownBy(() -> new DefaultMcpSession(null, transport)).isInstanceOf(IllegalArgumentException.class)
+ assertThatThrownBy(() -> new DefaultMcpSession(null, transport, Map.of(), Map.of()))
+ .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("requstTimeout can not be null");
- assertThatThrownBy(() -> new DefaultMcpSession(TIMEOUT, null)).isInstanceOf(IllegalArgumentException.class)
+ assertThatThrownBy(() -> new DefaultMcpSession(TIMEOUT, null, Map.of(), Map.of()))
+ .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("transport can not be null");
}