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:
Christian Tzolov 2025-03-13 15:13:50 +01:00 • committed by GitHub
commit 109aab20e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 613 additions and 242 deletions

View file

@ -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();

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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);
}
}