feat(client): Improve initialization state handling in McpAsyncClient (#39)
- Add proper initialization state tracking using AtomicBoolean and Sinks - Implement timeout handling for requests requiring initialization - Ensure all client methods verify initialization state before proceeding - Fix rootsListChangedNotification to check initialization state - Improve error messages for uninitialized client operations - improve JavaDoc - Add tests to verify proper error handling for uninitialized clients - Replace hardcoded timeout constants with configurable getTimeoutDuration() method - Remove automatic initialization in setUp methods to allow explicit testing Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
4eff00c8da
commit
109aab20e8
9 changed files with 613 additions and 242 deletions
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
@ -46,4 +48,9 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
|
|||
container.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofMillis(300);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
@ -46,4 +48,9 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests {
|
|||
container.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofMillis(300);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||
import java.util.function.Function;
|
||||
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
|
|
@ -47,8 +48,6 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
protected ClientMcpTransport mcpTransport;
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(20);
|
||||
|
||||
private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!";
|
||||
|
||||
abstract protected ClientMcpTransport createMcpTransport();
|
||||
|
|
@ -59,6 +58,10 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
protected void onClose() {
|
||||
}
|
||||
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofSeconds(2);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onStart();
|
||||
|
|
@ -66,10 +69,9 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
assertThatCode(() -> {
|
||||
mcpAsyncClient = McpClient.async(mcpTransport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.build();
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
|
@ -92,8 +94,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
.hasMessage("Request timeout must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListToolsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listTools(null).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListTools() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> {
|
||||
assertThat(result.tools()).isNotNull().isNotEmpty();
|
||||
|
||||
|
|
@ -103,13 +113,30 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPingWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.ping().block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before pinging the server");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPing() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolWithoutInitialization() {
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.callTool(callToolRequest).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before calling tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTool() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> {
|
||||
|
|
@ -122,13 +149,23 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
@Test
|
||||
void testCallToolWithInvalidTool() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourcesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listResources(null).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resources");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResources() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
|
||||
assertThat(resources).isNotNull().satisfies(result -> {
|
||||
assertThat(result.resources()).isNotNull();
|
||||
|
|
@ -147,8 +184,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
assertThat(mcpAsyncClient).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListPromptsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listPrompts(null).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing prompts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListPrompts() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> {
|
||||
assertThat(prompts).isNotNull().satisfies(result -> {
|
||||
assertThat(result.prompts()).isNotNull();
|
||||
|
|
@ -162,8 +207,18 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPromptWithoutInitialization() {
|
||||
GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.getPrompt(request).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before getting prompts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPrompt() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))
|
||||
.consumeNextWith(prompt -> {
|
||||
assertThat(prompt).isNotNull().satisfies(result -> {
|
||||
|
|
@ -174,8 +229,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChangedWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.rootsListChangedNotification().block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before sending roots list changed notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChanged() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +247,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.roots(new Root("file:///test/path", "test-root"))
|
||||
.build();
|
||||
|
||||
|
|
@ -233,8 +296,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplatesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listResourceTemplates().block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resource templates");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplates() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.resourceTemplates()).isNotNull();
|
||||
|
|
@ -266,7 +337,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
var transport = createMcpTransport();
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
|
||||
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
|
||||
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true)))
|
||||
|
|
@ -285,7 +356,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
var capabilities = ClientCapabilities.builder().sampling().build();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(capabilities)
|
||||
.sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build()))
|
||||
.build();
|
||||
|
|
@ -309,7 +380,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
|
||||
.just(CreateMessageResult.builder().message("test").model("test-model").build());
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(capabilities)
|
||||
.sampling(samplingHandler)
|
||||
.build();
|
||||
|
|
@ -326,8 +397,17 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevelsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG).block())
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before setting logging level");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete();
|
||||
|
|
@ -340,7 +420,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true)))
|
||||
.build();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import java.util.Map;
|
|||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
|
|
@ -41,8 +42,6 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
private McpSyncClient mcpSyncClient;
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private static final String TEST_MESSAGE = "Hello MCP Spring AI!";
|
||||
|
||||
protected ClientMcpTransport mcpTransport;
|
||||
|
|
@ -53,6 +52,10 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
abstract protected void onClose();
|
||||
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofSeconds(2);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onStart();
|
||||
|
|
@ -60,10 +63,9 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
assertThatCode(() -> {
|
||||
mcpSyncClient = McpClient.sync(mcpTransport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.build();
|
||||
mcpSyncClient.initialize();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +87,15 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
.hasMessage("Request timeout must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListToolsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.listTools(null)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListTools() {
|
||||
mcpSyncClient.initialize();
|
||||
ListToolsResult tools = mcpSyncClient.listTools(null);
|
||||
|
||||
assertThat(tools).isNotNull().satisfies(result -> {
|
||||
|
|
@ -98,8 +107,16 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before calling tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTools() {
|
||||
mcpSyncClient.initialize();
|
||||
CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)));
|
||||
|
||||
assertThat(toolResult).isNotNull().satisfies(result -> {
|
||||
|
|
@ -114,13 +131,29 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPingWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.ping()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before pinging the server");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPing() {
|
||||
mcpSyncClient.initialize();
|
||||
assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolWithoutInitialization() {
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncClient.callTool(callToolRequest)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before calling tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTool() {
|
||||
mcpSyncClient.initialize();
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
|
||||
|
||||
CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest);
|
||||
|
|
@ -138,13 +171,27 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChangedWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.rootsListChangedNotification()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before sending roots list changed notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChanged() {
|
||||
mcpSyncClient.initialize();
|
||||
assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourcesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.listResources(null)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resources");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResources() {
|
||||
mcpSyncClient.initialize();
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
assertThat(resources).isNotNull().satisfies(result -> {
|
||||
|
|
@ -168,7 +215,7 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.roots(new Root("file:///test/path", "test-root"))
|
||||
.build();
|
||||
|
||||
|
|
@ -204,8 +251,17 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
.hasMessageContaining("Root with uri 'nonexistent-uri' not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadResourceWithoutInitialization() {
|
||||
assertThatThrownBy(() -> {
|
||||
Resource resource = new Resource("test://uri", "Test Resource", null, null, null);
|
||||
mcpSyncClient.readResource(resource);
|
||||
}).isInstanceOf(McpError.class).hasMessage("Client must be initialized before reading resources");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadResource() {
|
||||
mcpSyncClient.initialize();
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
if (!resources.resources().isEmpty()) {
|
||||
|
|
@ -217,8 +273,15 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplatesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.listResourceTemplates(null)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resource templates");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplates() {
|
||||
mcpSyncClient.initialize();
|
||||
ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
|
|
@ -250,7 +313,7 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
var transport = createMcpTransport();
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
|
||||
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
|
||||
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
|
||||
|
|
@ -266,8 +329,16 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevelsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before setting logging level");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
mcpSyncClient.initialize();
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException();
|
||||
|
|
@ -280,7 +351,7 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.loggingConsumer(notification -> logReceived.set(true))
|
||||
.build();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import java.util.HashMap;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
|
@ -35,6 +37,7 @@ import org.slf4j.Logger;
|
|||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
/**
|
||||
* The Model Context Protocol (MCP) client implementation that provides asynchronous
|
||||
|
|
@ -79,6 +82,16 @@ public class McpAsyncClient {
|
|||
private static TypeReference<Void> VOID_TYPE_REFERENCE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
protected final Sinks.One<McpSchema.InitializeResult> initializedSink = Sinks.one();
|
||||
|
||||
private AtomicBoolean initialized = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* The max timeout to await for the client-server connection to be initialized.
|
||||
* Usually x2 the request timeout. // TODO should we make it configurable?
|
||||
*/
|
||||
private final Duration initializationTimeout;
|
||||
|
||||
/**
|
||||
* The MCP session implementation that manages bidirectional JSON-RPC communication
|
||||
* between clients and servers.
|
||||
|
|
@ -149,6 +162,7 @@ public class McpAsyncClient {
|
|||
this.clientCapabilities = features.clientCapabilities();
|
||||
this.transport = transport;
|
||||
this.roots = new ConcurrentHashMap<>(features.roots());
|
||||
this.initializationTimeout = requestTimeout.multipliedBy(2);
|
||||
|
||||
// Request Handlers
|
||||
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
|
||||
|
|
@ -216,70 +230,6 @@ public class McpAsyncClient {
|
|||
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Lifecycle
|
||||
// --------------------------
|
||||
/**
|
||||
* The initialization phase MUST be the first interaction between client and server.
|
||||
* During this phase, the client and server:
|
||||
* <ul>
|
||||
* <li>Establish protocol version compatibility</li>
|
||||
* <li>Exchange and negotiate capabilities</li>
|
||||
* <li>Share implementation details</li>
|
||||
* </ul>
|
||||
* <br/>
|
||||
* The client MUST initiate this phase by sending an initialize request containing:
|
||||
* <ul>
|
||||
* <li>The protocol version the client supports</li>
|
||||
* <li>The client's capabilities</li>
|
||||
* <li>Client implementation information</li>
|
||||
* </ul>
|
||||
*
|
||||
* The server MUST respond with its own capabilities and information:
|
||||
* {@link McpSchema.ServerCapabilities}. <br/>
|
||||
* After successful initialization, the client MUST send an initialized notification
|
||||
* to indicate it is ready to begin normal operations.
|
||||
*
|
||||
* <br/>
|
||||
*
|
||||
* <a href=
|
||||
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">Initialization
|
||||
* Spec</a>
|
||||
* @return the initialize result.
|
||||
*/
|
||||
public Mono<McpSchema.InitializeResult> initialize() {
|
||||
|
||||
String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
|
||||
|
||||
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
|
||||
latestVersion,
|
||||
this.clientCapabilities,
|
||||
this.clientInfo); // @formatter:on
|
||||
|
||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
|
||||
initializeRequest, new TypeReference<McpSchema.InitializeResult>() {
|
||||
});
|
||||
|
||||
return result.flatMap(initializeResult -> {
|
||||
|
||||
this.serverCapabilities = initializeResult.capabilities();
|
||||
this.serverInfo = initializeResult.serverInfo();
|
||||
|
||||
logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
|
||||
initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
|
||||
initializeResult.instructions());
|
||||
|
||||
if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
|
||||
return Mono.error(new McpError(
|
||||
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
||||
}
|
||||
else {
|
||||
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
|
||||
.thenReturn(initializeResult);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server capabilities that define the supported features and functionality.
|
||||
* @return The server capabilities
|
||||
|
|
@ -301,7 +251,7 @@ public class McpAsyncClient {
|
|||
* @return true if the client-server connection is initialized
|
||||
*/
|
||||
public boolean isInitialized() {
|
||||
return this.serverCapabilities != null;
|
||||
return this.initialized.get();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -335,6 +285,82 @@ public class McpAsyncClient {
|
|||
return this.mcpSession.closeGracefully();
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Initialization
|
||||
// --------------------------
|
||||
/**
|
||||
* The initialization phase MUST be the first interaction between client and server.
|
||||
* During this phase, the client and server:
|
||||
* <ul>
|
||||
* <li>Establish protocol version compatibility</li>
|
||||
* <li>Exchange and negotiate capabilities</li>
|
||||
* <li>Share implementation details</li>
|
||||
* </ul>
|
||||
* <br/>
|
||||
* The client MUST initiate this phase by sending an initialize request containing:
|
||||
* The protocol version the client supports, client's capabilities and clients
|
||||
* implementation information.
|
||||
* <p/>
|
||||
* The server MUST respond with its own capabilities and information.
|
||||
* <p/>
|
||||
* After successful initialization, the client MUST send an initialized notification
|
||||
* to indicate it is ready to begin normal operations.
|
||||
* @return the initialize result.
|
||||
* @see <a href=
|
||||
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">MCP
|
||||
* Initialization Spec</a>
|
||||
*/
|
||||
public Mono<McpSchema.InitializeResult> initialize() {
|
||||
|
||||
String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
|
||||
|
||||
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
|
||||
latestVersion,
|
||||
this.clientCapabilities,
|
||||
this.clientInfo); // @formatter:on
|
||||
|
||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
|
||||
initializeRequest, new TypeReference<McpSchema.InitializeResult>() {
|
||||
});
|
||||
|
||||
return result.flatMap(initializeResult -> {
|
||||
|
||||
this.serverCapabilities = initializeResult.capabilities();
|
||||
this.serverInfo = initializeResult.serverInfo();
|
||||
|
||||
logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
|
||||
initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
|
||||
initializeResult.instructions());
|
||||
|
||||
if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
|
||||
return Mono.error(new McpError(
|
||||
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
||||
}
|
||||
|
||||
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null).doOnSuccess(v -> {
|
||||
this.initialized.set(true);
|
||||
this.initializedSink.tryEmitValue(initializeResult);
|
||||
}).thenReturn(initializeResult);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to handle the common pattern of checking initialization before
|
||||
* executing an operation.
|
||||
* @param <T> The type of the result Mono
|
||||
* @param actionName The action to perform if the client is initialized
|
||||
* @param operation The operation to execute if the client is initialized
|
||||
* @return A Mono that completes with the result of the operation
|
||||
*/
|
||||
private <T> Mono<T> withInitializationCheck(String actionName,
|
||||
Function<McpSchema.InitializeResult, Mono<T>> operation) {
|
||||
return this.initializedSink.asMono()
|
||||
.timeout(this.initializationTimeout)
|
||||
.onErrorResume(TimeoutException.class,
|
||||
ex -> Mono.error(new McpError("Client must be initialized before " + actionName)))
|
||||
.flatMap(operation);
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Basic Utilites
|
||||
// --------------------------
|
||||
|
|
@ -344,8 +370,9 @@ public class McpAsyncClient {
|
|||
* @return A Mono that completes with the server's ping response
|
||||
*/
|
||||
public Mono<Object> ping() {
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_PING, null, new TypeReference<Object>() {
|
||||
});
|
||||
return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_PING, null, new TypeReference<Object>() {
|
||||
}));
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
|
|
@ -353,8 +380,8 @@ public class McpAsyncClient {
|
|||
// --------------------------
|
||||
/**
|
||||
* Adds a new root to the client's root list.
|
||||
* @param root The root to add
|
||||
* @return A Mono that completes when the root is added and notifications are sent
|
||||
* @param root The root to add.
|
||||
* @return A Mono that completes when the root is added and notifications are sent.
|
||||
*/
|
||||
public Mono<Void> addRoot(Root root) {
|
||||
|
||||
|
|
@ -375,15 +402,20 @@ public class McpAsyncClient {
|
|||
logger.debug("Added root: {}", root);
|
||||
|
||||
if (this.clientCapabilities.roots().listChanged()) {
|
||||
return this.rootsListChangedNotification();
|
||||
if (this.isInitialized()) {
|
||||
return this.rootsListChangedNotification();
|
||||
}
|
||||
else {
|
||||
logger.warn("Client is not initialized, ignore sending a roots list changed notification");
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a root from the client's root list.
|
||||
* @param rootUri The URI of the root to remove
|
||||
* @return A Mono that completes when the root is removed and notifications are sent
|
||||
* @param rootUri The URI of the root to remove.
|
||||
* @return A Mono that completes when the root is removed and notifications are sent.
|
||||
*/
|
||||
public Mono<Void> removeRoot(String rootUri) {
|
||||
|
||||
|
|
@ -400,7 +432,13 @@ public class McpAsyncClient {
|
|||
if (removed != null) {
|
||||
logger.debug("Removed Root: {}", rootUri);
|
||||
if (this.clientCapabilities.roots().listChanged()) {
|
||||
return this.rootsListChangedNotification();
|
||||
if (this.isInitialized()) {
|
||||
return this.rootsListChangedNotification();
|
||||
}
|
||||
else {
|
||||
logger.warn("Client is not initialized, ignore sending a roots list changed notification");
|
||||
}
|
||||
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
|
@ -409,11 +447,13 @@ public class McpAsyncClient {
|
|||
|
||||
/**
|
||||
* Manually sends a roots/list_changed notification. The addRoot and removeRoot
|
||||
* methods automatically send the roots/list_changed notification.
|
||||
* @return A Mono that completes when the notification is sent
|
||||
* methods automatically send the roots/list_changed notification if the client is in
|
||||
* an initialized state.
|
||||
* @return A Mono that completes when the notification is sent.
|
||||
*/
|
||||
public Mono<Void> rootsListChangedNotification() {
|
||||
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED);
|
||||
return this.withInitializationCheck("sending roots list changed notification",
|
||||
initResult -> this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED));
|
||||
}
|
||||
|
||||
private RequestHandler<McpSchema.ListRootsResult> rootsListRequestHandler() {
|
||||
|
|
@ -455,29 +495,25 @@ public class McpAsyncClient {
|
|||
* Calls a tool provided by the server. Tools enable servers to expose executable
|
||||
* functionality that can interact with external systems, perform computations, and
|
||||
* take actions in the real world.
|
||||
* @param callToolRequest The request containing: - name: The name of the tool to call
|
||||
* (must match a tool name from tools/list) - arguments: Arguments that conform to the
|
||||
* tool's input schema
|
||||
* @return A Mono that emits the tool execution result containing: - content: List of
|
||||
* content items (text, images, or embedded resources) representing the tool's output
|
||||
* - isError: Boolean indicating if the execution failed (true) or succeeded
|
||||
* (false/absent)
|
||||
* @param callToolRequest The request containing the tool name and input parameters.
|
||||
* @return A Mono that emits the result of the tool call, including the output and any
|
||||
* errors.
|
||||
* @see McpSchema.CallToolRequest
|
||||
* @see McpSchema.CallToolResult
|
||||
* @see #listTools()
|
||||
*/
|
||||
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
|
||||
if (!this.isInitialized()) {
|
||||
return Mono.error(new McpError("Client must be initialized before calling tools"));
|
||||
}
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("calling tools", initializedResult -> {
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all tools provided by the server.
|
||||
* @return A Mono that emits the list of tools result containing: - tools: List of
|
||||
* available tools, each with a name, description, and input schema - nextCursor:
|
||||
* Optional cursor for pagination if more tools are available
|
||||
* @return A Mono that emits the list of tools result.
|
||||
*/
|
||||
public Mono<McpSchema.ListToolsResult> listTools() {
|
||||
return this.listTools(null);
|
||||
|
|
@ -486,43 +522,29 @@ public class McpAsyncClient {
|
|||
/**
|
||||
* Retrieves a paginated list of tools provided by the server.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return A Mono that emits the list of tools result containing: - tools: List of
|
||||
* available tools, each with a name, description, and input schema - nextCursor:
|
||||
* Optional cursor for pagination if more tools are available
|
||||
* @return A Mono that emits the list of tools result
|
||||
*/
|
||||
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
|
||||
if (!this.isInitialized()) {
|
||||
return Mono.error(new McpError("Client must be initialized before listing tools"));
|
||||
}
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_TOOLS_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("listing tools", initializedResult -> {
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_TOOLS_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification handler for tools/list_changed notifications from the
|
||||
* server. When the server's available tools change, it sends a notification to inform
|
||||
* connected clients. This handler automatically fetches the updated tool list and
|
||||
* distributes it to all registered consumers.
|
||||
* @param toolsChangeConsumers List of consumers that will be notified when the tools
|
||||
* list changes. Each consumer receives the complete updated list of tools.
|
||||
* @return A NotificationHandler that processes tools/list_changed notifications by:
|
||||
* 1. Fetching the current list of tools from the server 2. Distributing the updated
|
||||
* list to all registered consumers 3. Handling any errors that occur during this
|
||||
* process
|
||||
*/
|
||||
private NotificationHandler asyncToolsChangeNotificationHandler(
|
||||
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers) {
|
||||
// TODO: params are not used yet
|
||||
return params -> listTools().flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
|
||||
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling tools list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
return params -> this.listTools()
|
||||
.flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
|
||||
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling tools list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
|
|
@ -539,107 +561,122 @@ public class McpAsyncClient {
|
|||
};
|
||||
|
||||
/**
|
||||
* Send a resources/list request.
|
||||
* @return A Mono that completes with the list of resources result
|
||||
* Retrieves the list of all resources provided by the server. Resources represent any
|
||||
* kind of UTF-8 encoded data that an MCP server makes available to clients, such as
|
||||
* database records, API responses, log files, and more.
|
||||
* @return A Mono that completes with the list of resources result.
|
||||
* @see McpSchema.ListResourcesResult
|
||||
* @see #readResource(McpSchema.Resource)
|
||||
*/
|
||||
public Mono<McpSchema.ListResourcesResult> listResources() {
|
||||
return this.listResources(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/list request.
|
||||
* @param cursor the cursor for pagination
|
||||
* @return A Mono that completes with the list of resources result
|
||||
* Retrieves a paginated list of resources provided by the server. Resources represent
|
||||
* any kind of UTF-8 encoded data that an MCP server makes available to clients, such
|
||||
* as database records, API responses, log files, and more.
|
||||
* @param cursor Optional pagination cursor from a previous list request.
|
||||
* @return A Mono that completes with the list of resources result.
|
||||
* @see McpSchema.ListResourcesResult
|
||||
* @see #readResource(McpSchema.Resource)
|
||||
*/
|
||||
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
|
||||
if (!this.isInitialized()) {
|
||||
return Mono.error(new McpError("Client must be initialized before listing resources"));
|
||||
}
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_RESOURCES_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("listing resources", initializedResult -> {
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_RESOURCES_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/read request.
|
||||
* @param resource the resource to read
|
||||
* @return A Mono that completes with the resource content
|
||||
* Reads the content of a specific resource identified by the provided Resource
|
||||
* object. This method fetches the actual data that the resource represents.
|
||||
* @param resource The resource to read, containing the URI that identifies the
|
||||
* resource.
|
||||
* @return A Mono that completes with the resource content.
|
||||
* @see McpSchema.Resource
|
||||
* @see McpSchema.ReadResourceResult
|
||||
*/
|
||||
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.Resource resource) {
|
||||
return this.readResource(new McpSchema.ReadResourceRequest(resource.uri()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/read request.
|
||||
* @param readResourceRequest the read resource request
|
||||
* @return A Mono that completes with the resource content
|
||||
* Reads the content of a specific resource identified by the provided request. This
|
||||
* method fetches the actual data that the resource represents.
|
||||
* @param readResourceRequest The request containing the URI of the resource to read
|
||||
* @return A Mono that completes with the resource content.
|
||||
* @see McpSchema.ReadResourceRequest
|
||||
* @see McpSchema.ReadResourceResult
|
||||
*/
|
||||
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
|
||||
if (!this.isInitialized()) {
|
||||
return Mono.error(new McpError("Client must be initialized before reading resources"));
|
||||
}
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
|
||||
READ_RESOURCE_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("reading resources", initializedResult -> {
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
|
||||
READ_RESOURCE_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource templates allow servers to expose parameterized resources using URI
|
||||
* templates. Arguments may be auto-completed through the completion API.
|
||||
*
|
||||
* Request a list of resource templates the server has.
|
||||
* @return A Mono that completes with the list of resource templates result
|
||||
* Retrieves the list of all resource templates provided by the server. Resource
|
||||
* templates allow servers to expose parameterized resources using URI templates,
|
||||
* enabling dynamic resource access based on variable parameters.
|
||||
* @return A Mono that completes with the list of resource templates result.
|
||||
* @see McpSchema.ListResourceTemplatesResult
|
||||
*/
|
||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
|
||||
return this.listResourceTemplates(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource templates allow servers to expose parameterized resources using URI
|
||||
* templates. Arguments may be auto-completed through the completion API.
|
||||
*
|
||||
* Request a list of resource templates the server has.
|
||||
* @param cursor the cursor for pagination
|
||||
* @return A Mono that completes with the list of resource templates result
|
||||
* Retrieves a paginated list of resource templates provided by the server. Resource
|
||||
* templates allow servers to expose parameterized resources using URI templates,
|
||||
* enabling dynamic resource access based on variable parameters.
|
||||
* @param cursor Optional pagination cursor from a previous list request.
|
||||
* @return A Mono that completes with the list of resource templates result.
|
||||
* @see McpSchema.ListResourceTemplatesResult
|
||||
*/
|
||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
|
||||
if (!this.isInitialized()) {
|
||||
return Mono.error(new McpError("Client must be initialized before listing resource templates"));
|
||||
}
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
|
||||
new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("listing resource templates", initializedResult -> {
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
|
||||
new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions. The protocol supports optional subscriptions to resource changes.
|
||||
* Clients can subscribe to specific resources and receive notifications when they
|
||||
* change.
|
||||
*
|
||||
* Send a resources/subscribe request.
|
||||
* @param subscribeRequest the subscribe request contains the uri of the resource to
|
||||
* subscribe to
|
||||
* @return A Mono that completes when the subscription is complete
|
||||
* Subscribes to changes in a specific resource. When the resource changes on the
|
||||
* server, the client will receive notifications through the resources change
|
||||
* notification handler.
|
||||
* @param subscribeRequest The subscribe request containing the URI of the resource.
|
||||
* @return A Mono that completes when the subscription is complete.
|
||||
* @see McpSchema.SubscribeRequest
|
||||
* @see #unsubscribeResource(McpSchema.UnsubscribeRequest)
|
||||
*/
|
||||
public Mono<Void> subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE);
|
||||
return this.withInitializationCheck("subscribing to resources", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/unsubscribe request.
|
||||
* @param unsubscribeRequest the unsubscribe request contains the uri of the resource
|
||||
* to unsubscribe from
|
||||
* @return A Mono that completes when the unsubscription is complete
|
||||
* Cancels an existing subscription to a resource. After unsubscribing, the client
|
||||
* will no longer receive notifications when the resource changes.
|
||||
* @param unsubscribeRequest The unsubscribe request containing the URI of the
|
||||
* resource.
|
||||
* @return A Mono that completes when the unsubscription is complete.
|
||||
* @see McpSchema.UnsubscribeRequest
|
||||
* @see #subscribeResource(McpSchema.SubscribeRequest)
|
||||
*/
|
||||
public Mono<Void> unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest,
|
||||
VOID_TYPE_REFERENCE);
|
||||
return this.withInitializationCheck("unsubscribing from resources", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, VOID_TYPE_REFERENCE));
|
||||
}
|
||||
|
||||
private NotificationHandler asyncResourcesChangeNotificationHandler(
|
||||
|
|
@ -663,30 +700,39 @@ public class McpAsyncClient {
|
|||
};
|
||||
|
||||
/**
|
||||
* List all available prompts.
|
||||
* @return A Mono that completes with the list of prompts result
|
||||
* Retrieves the list of all prompts provided by the server.
|
||||
* @return A Mono that completes with the list of prompts result.
|
||||
* @see McpSchema.ListPromptsResult
|
||||
* @see #getPrompt(GetPromptRequest)
|
||||
*/
|
||||
public Mono<ListPromptsResult> listPrompts() {
|
||||
return this.listPrompts(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available prompts.
|
||||
* @param cursor the cursor for pagination
|
||||
* @return A Mono that completes with the list of prompts result
|
||||
* Retrieves a paginated list of prompts provided by the server.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return A Mono that completes with the list of prompts result.
|
||||
* @see McpSchema.ListPromptsResult
|
||||
* @see #getPrompt(GetPromptRequest)
|
||||
*/
|
||||
public Mono<ListPromptsResult> listPrompts(String cursor) {
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor),
|
||||
LIST_PROMPTS_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("listing prompts", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prompt by its id.
|
||||
* @param getPromptRequest the get prompt request
|
||||
* @return A Mono that completes with the get prompt result
|
||||
* Retrieves a specific prompt by its ID. This provides the complete prompt template
|
||||
* including all parameters and instructions for generating AI content.
|
||||
* @param getPromptRequest The request containing the ID of the prompt to retrieve.
|
||||
* @return A Mono that completes with the prompt result.
|
||||
* @see McpSchema.GetPromptRequest
|
||||
* @see McpSchema.GetPromptResult
|
||||
* @see #listPrompts()
|
||||
*/
|
||||
public Mono<GetPromptResult> getPrompt(GetPromptRequest getPromptRequest) {
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF);
|
||||
return this.withInitializationCheck("getting prompts", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF));
|
||||
}
|
||||
|
||||
private NotificationHandler asyncPromptsChangeNotificationHandler(
|
||||
|
|
@ -703,14 +749,6 @@ public class McpAsyncClient {
|
|||
// --------------------------
|
||||
// Logging
|
||||
// --------------------------
|
||||
/**
|
||||
* Create a notification handler for logging notifications from the server. This
|
||||
* handler automatically distributes logging messages to all registered consumers.
|
||||
* @param loggingConsumers List of consumers that will be notified when a logging
|
||||
* message is received. Each consumer receives the logging message notification.
|
||||
* @return A NotificationHandler that processes log notifications by distributing the
|
||||
* message to all registered consumers
|
||||
*/
|
||||
private NotificationHandler asyncLoggingNotificationHandler(
|
||||
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
|
||||
|
||||
|
|
@ -726,18 +764,21 @@ public class McpAsyncClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Client can set the minimum logging level it wants to receive from the server.
|
||||
* @param loggingLevel the min logging level
|
||||
* Sets the minimum logging level for messages received from the server. The client
|
||||
* will only receive log messages at or above the specified severity level.
|
||||
* @param loggingLevel The minimum logging level to receive.
|
||||
* @return A Mono that completes when the logging level is set.
|
||||
* @see McpSchema.LoggingLevel
|
||||
*/
|
||||
public Mono<Void> setLoggingLevel(LoggingLevel loggingLevel) {
|
||||
Assert.notNull(loggingLevel, "Logging level must not be null");
|
||||
|
||||
String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference<String>() {
|
||||
return this.withInitializationCheck("setting logging level", initializedResult -> {
|
||||
String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference<String>() {
|
||||
});
|
||||
Map<String, Object> params = Map.of("level", levelName);
|
||||
return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params);
|
||||
});
|
||||
|
||||
Map<String, Object> params = Map.of("level", levelName);
|
||||
|
||||
return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||
import java.util.function.Function;
|
||||
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
|
|
@ -48,8 +49,6 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
protected ClientMcpTransport mcpTransport;
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(20);
|
||||
|
||||
private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!";
|
||||
|
||||
abstract protected ClientMcpTransport createMcpTransport();
|
||||
|
|
@ -60,6 +59,10 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
protected void onClose() {
|
||||
}
|
||||
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofSeconds(2);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onStart();
|
||||
|
|
@ -67,10 +70,9 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
assertThatCode(() -> {
|
||||
mcpAsyncClient = McpClient.async(mcpTransport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.build();
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
|
@ -93,8 +95,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
.hasMessage("Request timeout must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListToolsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listTools(null).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListTools() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> {
|
||||
assertThat(result.tools()).isNotNull().isNotEmpty();
|
||||
|
||||
|
|
@ -104,13 +114,30 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPingWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.ping().block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before pinging the server");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPing() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolWithoutInitialization() {
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.callTool(callToolRequest).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before calling tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTool() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> {
|
||||
|
|
@ -123,13 +150,23 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
@Test
|
||||
void testCallToolWithInvalidTool() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourcesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listResources(null).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resources");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResources() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
|
||||
assertThat(resources).isNotNull().satisfies(result -> {
|
||||
assertThat(result.resources()).isNotNull();
|
||||
|
|
@ -148,8 +185,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
assertThat(mcpAsyncClient).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListPromptsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listPrompts(null).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing prompts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListPrompts() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> {
|
||||
assertThat(prompts).isNotNull().satisfies(result -> {
|
||||
assertThat(result.prompts()).isNotNull();
|
||||
|
|
@ -163,8 +208,18 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPromptWithoutInitialization() {
|
||||
GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> mcpAsyncClient.getPrompt(request).block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before getting prompts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetPrompt() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))
|
||||
.consumeNextWith(prompt -> {
|
||||
assertThat(prompt).isNotNull().satisfies(result -> {
|
||||
|
|
@ -175,8 +230,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChangedWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.rootsListChangedNotification().block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before sending roots list changed notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChanged() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +248,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.roots(new Root("file:///test/path", "test-root"))
|
||||
.build();
|
||||
|
||||
|
|
@ -234,8 +297,16 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplatesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.listResourceTemplates().block()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resource templates");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplates() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.resourceTemplates()).isNotNull();
|
||||
|
|
@ -267,7 +338,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
|
||||
var transport = createMcpTransport();
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true)))
|
||||
.resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true)))
|
||||
.promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true)))
|
||||
|
|
@ -286,7 +357,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
var capabilities = ClientCapabilities.builder().sampling().build();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(capabilities)
|
||||
.sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build()))
|
||||
.build();
|
||||
|
|
@ -310,7 +381,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
|
||||
.just(CreateMessageResult.builder().message("test").model("test-model").build());
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(capabilities)
|
||||
.sampling(samplingHandler)
|
||||
.build();
|
||||
|
|
@ -327,8 +398,17 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevelsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG).block())
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before setting logging level");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete();
|
||||
|
|
@ -341,7 +421,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.async(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true)))
|
||||
.build();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import java.util.Map;
|
|||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
|
|
@ -42,8 +43,6 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
private McpSyncClient mcpSyncClient;
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private static final String TEST_MESSAGE = "Hello MCP Spring AI!";
|
||||
|
||||
protected ClientMcpTransport mcpTransport;
|
||||
|
|
@ -54,6 +53,10 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
abstract protected void onClose();
|
||||
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofSeconds(2);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
onStart();
|
||||
|
|
@ -61,10 +64,9 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
assertThatCode(() -> {
|
||||
mcpSyncClient = McpClient.sync(mcpTransport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.capabilities(ClientCapabilities.builder().roots(true).build())
|
||||
.build();
|
||||
mcpSyncClient.initialize();
|
||||
}).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
|
@ -86,8 +88,15 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
.hasMessage("Request timeout must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListToolsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.listTools(null)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListTools() {
|
||||
mcpSyncClient.initialize();
|
||||
ListToolsResult tools = mcpSyncClient.listTools(null);
|
||||
|
||||
assertThat(tools).isNotNull().satisfies(result -> {
|
||||
|
|
@ -99,8 +108,16 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before calling tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTools() {
|
||||
mcpSyncClient.initialize();
|
||||
CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)));
|
||||
|
||||
assertThat(toolResult).isNotNull().satisfies(result -> {
|
||||
|
|
@ -115,13 +132,29 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPingWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.ping()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before pinging the server");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPing() {
|
||||
mcpSyncClient.initialize();
|
||||
assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallToolWithoutInitialization() {
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
|
||||
|
||||
assertThatThrownBy(() -> mcpSyncClient.callTool(callToolRequest)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before calling tools");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCallTool() {
|
||||
mcpSyncClient.initialize();
|
||||
CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE));
|
||||
|
||||
CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest);
|
||||
|
|
@ -139,13 +172,27 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChangedWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.rootsListChangedNotification()).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before sending roots list changed notification");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRootsListChanged() {
|
||||
mcpSyncClient.initialize();
|
||||
assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourcesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.listResources(null)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resources");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResources() {
|
||||
mcpSyncClient.initialize();
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
assertThat(resources).isNotNull().satisfies(result -> {
|
||||
|
|
@ -169,7 +216,7 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.roots(new Root("file:///test/path", "test-root"))
|
||||
.build();
|
||||
|
||||
|
|
@ -205,8 +252,17 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
.hasMessageContaining("Root with uri 'nonexistent-uri' not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadResourceWithoutInitialization() {
|
||||
assertThatThrownBy(() -> {
|
||||
Resource resource = new Resource("test://uri", "Test Resource", null, null, null);
|
||||
mcpSyncClient.readResource(resource);
|
||||
}).isInstanceOf(McpError.class).hasMessage("Client must be initialized before reading resources");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReadResource() {
|
||||
mcpSyncClient.initialize();
|
||||
ListResourcesResult resources = mcpSyncClient.listResources(null);
|
||||
|
||||
if (!resources.resources().isEmpty()) {
|
||||
|
|
@ -218,8 +274,15 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplatesWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.listResourceTemplates(null)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before listing resource templates");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListResourceTemplates() {
|
||||
mcpSyncClient.initialize();
|
||||
ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
|
|
@ -251,7 +314,7 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
|
||||
var transport = createMcpTransport();
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true))
|
||||
.resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true))
|
||||
.promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true))
|
||||
|
|
@ -267,8 +330,16 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
// Logging Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testLoggingLevelsWithoutInitialization() {
|
||||
assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG))
|
||||
.isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be initialized before setting logging level");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoggingLevels() {
|
||||
mcpSyncClient.initialize();
|
||||
// Test all logging levels
|
||||
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
|
||||
assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException();
|
||||
|
|
@ -281,7 +352,7 @@ public abstract class AbstractMcpSyncClientTests {
|
|||
var transport = createMcpTransport();
|
||||
|
||||
var client = McpClient.sync(transport)
|
||||
.requestTimeout(TIMEOUT)
|
||||
.requestTimeout(getTimeoutDuration())
|
||||
.loggingConsumer(notification -> logReceived.set(true))
|
||||
.build();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
@ -44,4 +46,9 @@ class ServletSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests {
|
|||
container.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofMillis(300);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.spec.ClientMcpTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
@ -44,4 +46,9 @@ class ServletSseMcpSyncClientTests extends AbstractMcpSyncClientTests {
|
|||
container.stop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Duration getTimeoutDuration() {
|
||||
return Duration.ofMillis(300);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue