refactor(client): improve validation and remove server methods (#14)

Add client initialization and capability validation checks

- New isInitialized() method to check client state
- Validate server capabilities before tool/resource operations
- Add clear error messages for common failure cases
- Remove server-side notification methods from client: sendResourcesListChanged(), promptListChangedNotification()
- Improve protocol version handling
- Testing improvements and new initialization tests
- Redesign MockMcpTransport internals and adapt tests
- Correct McpError messages
- clean unused imports

Resolves #13

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
Co-authored-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-02-27 10:00:04 +01:00 • committed by GitHub
commit 718042644a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 184 additions and 112 deletions

View file

@ -4,7 +4,9 @@
package io.modelcontextprotocol; package io.modelcontextprotocol;
import java.util.concurrent.atomic.AtomicInteger; import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function; import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
@ -14,47 +16,53 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.ServerMcpTransport; import io.modelcontextprotocol.spec.ServerMcpTransport;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks; import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
@SuppressWarnings("unused") /**
* A mock implementation of the {@link ClientMcpTransport} and {@link ServerMcpTransport}
* interfaces.
*/
public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport { public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport {
private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
private final Sinks.Many<McpSchema.JSONRPCMessage> outgoing = Sinks.many().multicast().onBackpressureBuffer();
private final Sinks.Many<McpSchema.JSONRPCMessage> inbound = Sinks.many().unicast().onBackpressureBuffer(); private final Sinks.Many<McpSchema.JSONRPCMessage> inbound = Sinks.many().unicast().onBackpressureBuffer();
private final Flux<McpSchema.JSONRPCMessage> outboundView = outgoing.asFlux().cache(1); private final List<McpSchema.JSONRPCMessage> sent = new ArrayList<>();
private final BiConsumer<MockMcpTransport, McpSchema.JSONRPCMessage> interceptor;
public MockMcpTransport() {
this((t, msg) -> {
});
}
public MockMcpTransport(BiConsumer<MockMcpTransport, McpSchema.JSONRPCMessage> interceptor) {
this.interceptor = interceptor;
}
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) { public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
if (inbound.tryEmitNext(message).isFailure()) { if (inbound.tryEmitNext(message).isFailure()) {
throw new RuntimeException("Failed to emit message " + message); throw new RuntimeException("Failed to process incoming message " + message);
} }
inboundMessageCount.incrementAndGet();
} }
@Override @Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) { public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
if (outgoing.tryEmitNext(message).isFailure()) { sent.add(message);
return Mono.error(new RuntimeException("Can't emit outgoing message " + message)); interceptor.accept(this, message);
}
return Mono.empty(); return Mono.empty();
} }
public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() { public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
return (JSONRPCRequest) outboundView.blockFirst(); return (JSONRPCRequest) getLastSentMessage();
} }
public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() { public McpSchema.JSONRPCNotification getLastSentMessageAsNotification() {
return (JSONRPCNotification) outboundView.blockFirst(); return (JSONRPCNotification) getLastSentMessage();
} }
public McpSchema.JSONRPCMessage getLastSentMessage() { public McpSchema.JSONRPCMessage getLastSentMessage() {
return outboundView.blockFirst(); return !sent.isEmpty() ? sent.get(sent.size() - 1) : null;
} }
private volatile boolean connected = false; private volatile boolean connected = false;
@ -66,7 +74,6 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport
} }
connected = true; connected = true;
return inbound.asFlux() return inbound.asFlux()
.publishOn(Schedulers.boundedElastic())
.flatMap(message -> Mono.just(message).transform(handler)) .flatMap(message -> Mono.just(message).transform(handler))
.doFinally(signal -> connected = false) .doFinally(signal -> connected = false)
.then(); .then();
@ -76,8 +83,8 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport
public Mono<Void> closeGracefully() { public Mono<Void> closeGracefully() {
return Mono.defer(() -> { return Mono.defer(() -> {
connected = false; connected = false;
outgoing.tryEmitComplete();
inbound.tryEmitComplete(); inbound.tryEmitComplete();
// Wait for all subscribers to complete
return Mono.empty(); return Mono.empty();
}); });
} }

View file

@ -274,9 +274,6 @@ public abstract class AbstractMcpAsyncClientTests {
assertThatCode(() -> { assertThatCode(() -> {
client.initialize().block(); client.initialize().block();
// Trigger notifications
client.sendResourcesListChanged().block();
client.promptListChangedNotification().block();
client.closeGracefully().block(); client.closeGracefully().block();
}).doesNotThrowAnyException(); }).doesNotThrowAnyException();
} }

View file

@ -258,9 +258,6 @@ public abstract class AbstractMcpSyncClientTests {
assertThatCode(() -> { assertThatCode(() -> {
client.initialize(); client.initialize();
// Trigger notifications
client.sendResourcesListChanged();
client.promptListChangedNotification();
client.close(); client.close();
}).doesNotThrowAnyException(); }).doesNotThrowAnyException();
} }

View file

@ -296,6 +296,14 @@ public class McpAsyncClient {
return this.serverInfo; return this.serverInfo;
} }
/**
* Check if the client-server connection is initialized.
* @return true if the client-server connection is initialized
*/
public boolean isInitialized() {
return this.serverCapabilities != null;
}
/** /**
* Get the client capabilities that define the supported features and functionality. * Get the client capabilities that define the supported features and functionality.
* @return The client capabilities * @return The client capabilities
@ -456,6 +464,12 @@ public class McpAsyncClient {
* (false/absent) * (false/absent)
*/ */
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) { 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.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
} }
@ -477,6 +491,12 @@ public class McpAsyncClient {
* Optional cursor for pagination if more tools are available * Optional cursor for pagination if more tools are available
*/ */
public Mono<McpSchema.ListToolsResult> listTools(String cursor) { 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), return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
LIST_TOOLS_RESULT_TYPE_REF); LIST_TOOLS_RESULT_TYPE_REF);
} }
@ -532,6 +552,12 @@ public class McpAsyncClient {
* @return A Mono that completes with the list of resources result * @return A Mono that completes with the list of resources result
*/ */
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) { 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), return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
LIST_RESOURCES_RESULT_TYPE_REF); LIST_RESOURCES_RESULT_TYPE_REF);
} }
@ -551,6 +577,12 @@ public class McpAsyncClient {
* @return A Mono that completes with the resource content * @return A Mono that completes with the resource content
*/ */
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) { 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, return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
READ_RESOURCE_RESULT_TYPE_REF); READ_RESOURCE_RESULT_TYPE_REF);
} }
@ -575,19 +607,16 @@ public class McpAsyncClient {
* @return A Mono that completes with the list of resource templates result * @return A Mono that completes with the list of resource templates result
*/ */
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) { 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, return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
} }
/**
* List Changed Notification. When the list of available resources changes, servers
* that declared the listChanged capability SHOULD send a notification.
* @return A Mono that completes when the notification is sent
*/
public Mono<Void> sendResourcesListChanged() {
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED);
}
/** /**
* Subscriptions. The protocol supports optional subscriptions to resource changes. * Subscriptions. The protocol supports optional subscriptions to resource changes.
* Clients can subscribe to specific resources and receive notifications when they * Clients can subscribe to specific resources and receive notifications when they
@ -660,16 +689,6 @@ public class McpAsyncClient {
return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF); return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF);
} }
/**
* (Server) An optional notification from the server to the client, informing it that
* the list of prompts it offers has changed. This may be issued by servers without
* any previous subscription from the client.
* @return A Mono that completes when the notification is sent
*/
public Mono<Void> promptListChangedNotification() {
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED);
}
private NotificationHandler asyncPromptsChangeNotificationHandler( private NotificationHandler asyncPromptsChangeNotificationHandler(
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers) { List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers) {
return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers) return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)

View file

@ -284,14 +284,6 @@ public class McpSyncClient implements AutoCloseable {
return this.delegate.listResourceTemplates().block(); return this.delegate.listResourceTemplates().block();
} }
/**
* List Changed Notification. When the list of available resources changes, servers
* that declared the listChanged capability SHOULD send a notification:
*/
public void sendResourcesListChanged() {
this.delegate.sendResourcesListChanged().block();
}
/** /**
* Subscriptions. The protocol supports optional subscriptions to resource changes. * Subscriptions. The protocol supports optional subscriptions to resource changes.
* Clients can subscribe to specific resources and receive notifications when they * Clients can subscribe to specific resources and receive notifications when they
@ -329,15 +321,6 @@ public class McpSyncClient implements AutoCloseable {
return this.delegate.getPrompt(getPromptRequest).block(); return this.delegate.getPrompt(getPromptRequest).block();
} }
/**
* (Server) An optional notification from the server to the client, informing it that
* the list of prompts it offers has changed. This may be issued by servers without
* any previous subscription from the client.
*/
public void promptListChangedNotification() {
this.delegate.promptListChangedNotification().block();
}
/** /**
* Client can set the minimum logging level it wants to receive from the server. * Client can set the minimum logging level it wants to receive from the server.
* @param loggingLevel the min logging level * @param loggingLevel the min logging level

View file

@ -188,9 +188,13 @@ public class McpAsyncServer {
initializeRequest.protocolVersion(), initializeRequest.capabilities(), initializeRequest.protocolVersion(), initializeRequest.capabilities(),
initializeRequest.clientInfo()); initializeRequest.clientInfo());
// The server MUST respond with the highest protocol version it supports if
// it does not support the requested (e.g. Client) version.
String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
if (this.protocolVersions.contains(initializeRequest.protocolVersion())) { if (this.protocolVersions.contains(initializeRequest.protocolVersion())) {
// If the server supports the requested protocol version, it MUST respond
// with the same version.
serverProtocolVersion = initializeRequest.protocolVersion(); serverProtocolVersion = initializeRequest.protocolVersion();
} }
else { else {

View file

@ -4,7 +4,9 @@
package io.modelcontextprotocol; package io.modelcontextprotocol;
import java.util.concurrent.atomic.AtomicInteger; import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function; import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
@ -14,10 +16,8 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.ServerMcpTransport; import io.modelcontextprotocol.spec.ServerMcpTransport;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks; import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
/** /**
* A mock implementation of the {@link ClientMcpTransport} and {@link ServerMcpTransport} * A mock implementation of the {@link ClientMcpTransport} and {@link ServerMcpTransport}
@ -25,39 +25,44 @@ import reactor.core.scheduler.Schedulers;
*/ */
public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport { public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport {
private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
private final Sinks.Many<McpSchema.JSONRPCMessage> outgoing = Sinks.many().multicast().onBackpressureBuffer();
private final Sinks.Many<McpSchema.JSONRPCMessage> inbound = Sinks.many().unicast().onBackpressureBuffer(); private final Sinks.Many<McpSchema.JSONRPCMessage> inbound = Sinks.many().unicast().onBackpressureBuffer();
private final Flux<McpSchema.JSONRPCMessage> outboundView = outgoing.asFlux().cache(1); private final List<McpSchema.JSONRPCMessage> sent = new ArrayList<>();
private final BiConsumer<MockMcpTransport, McpSchema.JSONRPCMessage> interceptor;
public MockMcpTransport() {
this((t, msg) -> {
});
}
public MockMcpTransport(BiConsumer<MockMcpTransport, McpSchema.JSONRPCMessage> interceptor) {
this.interceptor = interceptor;
}
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) { public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
if (inbound.tryEmitNext(message).isFailure()) { if (inbound.tryEmitNext(message).isFailure()) {
throw new RuntimeException("Failed to emit message " + message); throw new RuntimeException("Failed to process incoming message " + message);
} }
inboundMessageCount.incrementAndGet();
} }
@Override @Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) { public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
if (outgoing.tryEmitNext(message).isFailure()) { sent.add(message);
return Mono.error(new RuntimeException("Can't emit outgoing message " + message)); interceptor.accept(this, message);
}
return Mono.empty(); return Mono.empty();
} }
public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() { public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
return (JSONRPCRequest) outboundView.blockFirst(); return (JSONRPCRequest) getLastSentMessage();
} }
public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() { public McpSchema.JSONRPCNotification getLastSentMessageAsNotification() {
return (JSONRPCNotification) outboundView.blockFirst(); return (JSONRPCNotification) getLastSentMessage();
} }
public McpSchema.JSONRPCMessage getLastSentMessage() { public McpSchema.JSONRPCMessage getLastSentMessage() {
return outboundView.blockFirst(); return !sent.isEmpty() ? sent.get(sent.size() - 1) : null;
} }
private volatile boolean connected = false; private volatile boolean connected = false;
@ -69,7 +74,6 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport
} }
connected = true; connected = true;
return inbound.asFlux() return inbound.asFlux()
.publishOn(Schedulers.boundedElastic())
.flatMap(message -> Mono.just(message).transform(handler)) .flatMap(message -> Mono.just(message).transform(handler))
.doFinally(signal -> connected = false) .doFinally(signal -> connected = false)
.then(); .then();
@ -79,7 +83,6 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport
public Mono<Void> closeGracefully() { public Mono<Void> closeGracefully() {
return Mono.defer(() -> { return Mono.defer(() -> {
connected = false; connected = false;
outgoing.tryEmitComplete();
inbound.tryEmitComplete(); inbound.tryEmitComplete();
// Wait for all subscribers to complete // Wait for all subscribers to complete
return Mono.empty(); return Mono.empty();

View file

@ -275,9 +275,6 @@ public abstract class AbstractMcpAsyncClientTests {
assertThatCode(() -> { assertThatCode(() -> {
client.initialize().block(); client.initialize().block();
// Trigger notifications
client.sendResourcesListChanged().block();
client.promptListChangedNotification().block();
client.closeGracefully().block(); client.closeGracefully().block();
}).doesNotThrowAnyException(); }).doesNotThrowAnyException();
} }

View file

@ -259,9 +259,6 @@ public abstract class AbstractMcpSyncClientTests {
assertThatCode(() -> { assertThatCode(() -> {
client.initialize(); client.initialize();
// Trigger notifications
client.sendResourcesListChanged();
client.promptListChangedNotification();
client.close(); client.close();
}).doesNotThrowAnyException(); }).doesNotThrowAnyException();
} }

View file

@ -4,7 +4,6 @@
package io.modelcontextprotocol.client; package io.modelcontextprotocol.client;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -17,19 +16,82 @@ import io.modelcontextprotocol.MockMcpTransport;
import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.Root; import io.modelcontextprotocol.spec.McpSchema.Root;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import static io.modelcontextprotocol.spec.McpSchema.METHOD_INITIALIZE;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
class McpAsyncClientResponseHandlerTests { class McpAsyncClientResponseHandlerTests {
private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0");
private static final McpSchema.ServerCapabilities SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder()
.tools(true)
.resources(true, true) // Enable both resources and resource templates
.build();
private static MockMcpTransport initializationEnabledTransport() {
return initializationEnabledTransport(SERVER_CAPABILITIES, SERVER_INFO);
}
private static MockMcpTransport initializationEnabledTransport(McpSchema.ServerCapabilities mockServerCapabilities,
McpSchema.Implementation mockServerInfo) {
McpSchema.InitializeResult mockInitResult = new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION,
mockServerCapabilities, mockServerInfo, "Test instructions");
return new MockMcpTransport((t, message) -> {
if (message instanceof McpSchema.JSONRPCRequest r && METHOD_INITIALIZE.equals(r.method())) {
McpSchema.JSONRPCResponse initResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION,
r.id(), mockInitResult, null);
t.simulateIncomingMessage(initResponse);
}
});
}
@Test
void testSuccessfulInitialization() {
McpSchema.Implementation serverInfo = new McpSchema.Implementation("mcp-test-server", "0.0.1");
McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder()
.tools(false)
.resources(true, true) // Enable both resources and resource templates
.build();
MockMcpTransport transport = initializationEnabledTransport(serverCapabilities, serverInfo);
McpAsyncClient asyncMcpClient = McpClient.async(transport).build();
// Verify client is not initialized initially
assertThat(asyncMcpClient.isInitialized()).isFalse();
// Start initialization with reactive handling
InitializeResult result = asyncMcpClient.initialize().block();
// Verify initialized notification was sent
McpSchema.JSONRPCMessage notificationMessage = transport.getLastSentMessage();
assertThat(notificationMessage).isInstanceOf(McpSchema.JSONRPCNotification.class);
McpSchema.JSONRPCNotification notification = (McpSchema.JSONRPCNotification) notificationMessage;
assertThat(notification.method()).isEqualTo(McpSchema.METHOD_NOTIFICATION_INITIALIZED);
// Verify initialization result
assertThat(result).isNotNull();
assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION);
assertThat(result.capabilities()).isEqualTo(serverCapabilities);
assertThat(result.serverInfo()).isEqualTo(serverInfo);
assertThat(result.instructions()).isEqualTo("Test instructions");
// Verify client state after initialization
assertThat(asyncMcpClient.isInitialized()).isTrue();
assertThat(asyncMcpClient.getServerCapabilities()).isEqualTo(serverCapabilities);
assertThat(asyncMcpClient.getServerInfo()).isEqualTo(serverInfo);
asyncMcpClient.closeGracefully();
}
@Test @Test
void testToolsChangeNotificationHandling() throws JsonProcessingException { void testToolsChangeNotificationHandling() throws JsonProcessingException {
MockMcpTransport transport = new MockMcpTransport(); MockMcpTransport transport = initializationEnabledTransport();
// Create a list to store received tools for verification // Create a list to store received tools for verification
List<McpSchema.Tool> receivedTools = new ArrayList<>(); List<McpSchema.Tool> receivedTools = new ArrayList<>();
@ -41,6 +103,8 @@ class McpAsyncClientResponseHandlerTests {
// Create client with tools change consumer // Create client with tools change consumer
McpAsyncClient asyncMcpClient = McpClient.async(transport).toolsChangeConsumer(toolsChangeConsumer).build(); McpAsyncClient asyncMcpClient = McpClient.async(transport).toolsChangeConsumer(toolsChangeConsumer).build();
assertThat(asyncMcpClient.initialize().block()).isNotNull();
// Create a mock tools list that the server will return // Create a mock tools list that the server will return
Map<String, Object> inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of()); Map<String, Object> inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of());
McpSchema.Tool mockTool = new McpSchema.Tool("test-tool", "Test Tool Description", McpSchema.Tool mockTool = new McpSchema.Tool("test-tool", "Test Tool Description",
@ -61,23 +125,23 @@ class McpAsyncClientResponseHandlerTests {
transport.simulateIncomingMessage(toolsListResponse); transport.simulateIncomingMessage(toolsListResponse);
// Verify the consumer received the expected tools // Verify the consumer received the expected tools
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { assertThat(receivedTools).hasSize(1);
assertThat(receivedTools).hasSize(1); assertThat(receivedTools.get(0).name()).isEqualTo("test-tool");
assertThat(receivedTools.get(0).name()).isEqualTo("test-tool"); assertThat(receivedTools.get(0).description()).isEqualTo("Test Tool Description");
assertThat(receivedTools.get(0).description()).isEqualTo("Test Tool Description");
});
asyncMcpClient.closeGracefully(); asyncMcpClient.closeGracefully();
} }
@Test @Test
void testRootsListRequestHandling() { void testRootsListRequestHandling() {
MockMcpTransport transport = new MockMcpTransport(); MockMcpTransport transport = initializationEnabledTransport();
McpAsyncClient asyncMcpClient = McpClient.async(transport) McpAsyncClient asyncMcpClient = McpClient.async(transport)
.roots(new Root("file:///test/path", "test-root")) .roots(new Root("file:///test/path", "test-root"))
.build(); .build();
assertThat(asyncMcpClient.initialize().block()).isNotNull();
// Simulate incoming request // Simulate incoming request
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
McpSchema.METHOD_ROOTS_LIST, "test-id", null); McpSchema.METHOD_ROOTS_LIST, "test-id", null);
@ -98,7 +162,7 @@ class McpAsyncClientResponseHandlerTests {
@Test @Test
void testResourcesChangeNotificationHandling() { void testResourcesChangeNotificationHandling() {
MockMcpTransport transport = new MockMcpTransport(); MockMcpTransport transport = initializationEnabledTransport();
// Create a list to store received resources for verification // Create a list to store received resources for verification
List<McpSchema.Resource> receivedResources = new ArrayList<>(); List<McpSchema.Resource> receivedResources = new ArrayList<>();
@ -112,6 +176,8 @@ class McpAsyncClientResponseHandlerTests {
.resourcesChangeConsumer(resourcesChangeConsumer) .resourcesChangeConsumer(resourcesChangeConsumer)
.build(); .build();
assertThat(asyncMcpClient.initialize().block()).isNotNull();
// Create a mock resources list that the server will return // Create a mock resources list that the server will return
McpSchema.Resource mockResource = new McpSchema.Resource("test://resource", "Test Resource", "A test resource", McpSchema.Resource mockResource = new McpSchema.Resource("test://resource", "Test Resource", "A test resource",
"text/plain", null); "text/plain", null);
@ -132,19 +198,17 @@ class McpAsyncClientResponseHandlerTests {
transport.simulateIncomingMessage(resourcesListResponse); transport.simulateIncomingMessage(resourcesListResponse);
// Verify the consumer received the expected resources // Verify the consumer received the expected resources
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { assertThat(receivedResources).hasSize(1);
assertThat(receivedResources).hasSize(1); assertThat(receivedResources.get(0).uri()).isEqualTo("test://resource");
assertThat(receivedResources.get(0).uri()).isEqualTo("test://resource"); assertThat(receivedResources.get(0).name()).isEqualTo("Test Resource");
assertThat(receivedResources.get(0).name()).isEqualTo("Test Resource"); assertThat(receivedResources.get(0).description()).isEqualTo("A test resource");
assertThat(receivedResources.get(0).description()).isEqualTo("A test resource");
});
asyncMcpClient.closeGracefully(); asyncMcpClient.closeGracefully();
} }
@Test @Test
void testPromptsChangeNotificationHandling() { void testPromptsChangeNotificationHandling() {
MockMcpTransport transport = new MockMcpTransport(); MockMcpTransport transport = initializationEnabledTransport();
// Create a list to store received prompts for verification // Create a list to store received prompts for verification
List<McpSchema.Prompt> receivedPrompts = new ArrayList<>(); List<McpSchema.Prompt> receivedPrompts = new ArrayList<>();
@ -156,6 +220,8 @@ class McpAsyncClientResponseHandlerTests {
// Create client with prompts change consumer // Create client with prompts change consumer
McpAsyncClient asyncMcpClient = McpClient.async(transport).promptsChangeConsumer(promptsChangeConsumer).build(); McpAsyncClient asyncMcpClient = McpClient.async(transport).promptsChangeConsumer(promptsChangeConsumer).build();
assertThat(asyncMcpClient.initialize().block()).isNotNull();
// Create a mock prompts list that the server will return // Create a mock prompts list that the server will return
McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "Test Prompt Description", McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "Test Prompt Description",
List.of(new McpSchema.PromptArgument("arg1", "Test argument", true))); List.of(new McpSchema.PromptArgument("arg1", "Test argument", true)));
@ -175,20 +241,18 @@ class McpAsyncClientResponseHandlerTests {
transport.simulateIncomingMessage(promptsListResponse); transport.simulateIncomingMessage(promptsListResponse);
// Verify the consumer received the expected prompts // Verify the consumer received the expected prompts
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { assertThat(receivedPrompts).hasSize(1);
assertThat(receivedPrompts).hasSize(1); assertThat(receivedPrompts.get(0).name()).isEqualTo("test-prompt");
assertThat(receivedPrompts.get(0).name()).isEqualTo("test-prompt"); assertThat(receivedPrompts.get(0).description()).isEqualTo("Test Prompt Description");
assertThat(receivedPrompts.get(0).description()).isEqualTo("Test Prompt Description"); assertThat(receivedPrompts.get(0).arguments()).hasSize(1);
assertThat(receivedPrompts.get(0).arguments()).hasSize(1); assertThat(receivedPrompts.get(0).arguments().get(0).name()).isEqualTo("arg1");
assertThat(receivedPrompts.get(0).arguments().get(0).name()).isEqualTo("arg1");
});
asyncMcpClient.closeGracefully(); asyncMcpClient.closeGracefully();
} }
@Test @Test
void testSamplingCreateMessageRequestHandling() { void testSamplingCreateMessageRequestHandling() {
MockMcpTransport transport = new MockMcpTransport(); MockMcpTransport transport = initializationEnabledTransport();
// Create a test sampling handler that echoes back the input // Create a test sampling handler that echoes back the input
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = request -> { Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = request -> {
@ -203,6 +267,8 @@ class McpAsyncClientResponseHandlerTests {
.sampling(samplingHandler) .sampling(samplingHandler)
.build(); .build();
assertThat(asyncMcpClient.initialize().block()).isNotNull();
// Create a mock create message request // Create a mock create message request
var messageRequest = new McpSchema.CreateMessageRequest( var messageRequest = new McpSchema.CreateMessageRequest(
List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))), List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))),
@ -240,13 +306,15 @@ class McpAsyncClientResponseHandlerTests {
@Test @Test
void testSamplingCreateMessageRequestHandlingWithoutCapability() { void testSamplingCreateMessageRequestHandlingWithoutCapability() {
MockMcpTransport transport = new MockMcpTransport(); MockMcpTransport transport = initializationEnabledTransport();
// Create client without sampling capability // Create client without sampling capability
McpAsyncClient asyncMcpClient = McpClient.async(transport) McpAsyncClient asyncMcpClient = McpClient.async(transport)
.capabilities(ClientCapabilities.builder().build()) // No sampling capability .capabilities(ClientCapabilities.builder().build()) // No sampling capability
.build(); .build();
assertThat(asyncMcpClient.initialize().block()).isNotNull();
// Create a mock create message request // Create a mock create message request
var messageRequest = new McpSchema.CreateMessageRequest( var messageRequest = new McpSchema.CreateMessageRequest(
List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))), List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))),