Support resource subscriptions (#839)
Clients can now subscribe to specific resources and receive targeted notifications when those resources change. Previously, calling `notifyResourceUpdated` on the server would broadcast the notification to every connected client regardless of interest — now only sessions that have explicitly subscribed to a given resource URI receive the update, making resource change propagation both correct and efficient. The subscription lifecycle is fully handled: clients can subscribe and unsubscribe at any time, and the server cleans up subscription state when a session closes. Supersedes #838. Resolves #837, #776. --------- Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
parent
ce2c747c92
commit
7f68ea5bc0
4 changed files with 506 additions and 60 deletions
|
|
@ -1746,6 +1746,95 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resource Subscription Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@MethodSource("clientsForTesting")
|
||||
void testResourceSubscription(String clientType) throws InterruptedException {
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
String resourceUri = "test://subscribable-resource";
|
||||
var receivedContents = new AtomicReference<List<McpSchema.ResourceContents>>();
|
||||
var latch = new CountDownLatch(1);
|
||||
|
||||
McpServerFeatures.SyncResourceSpecification resourceSpec = new McpServerFeatures.SyncResourceSpecification(
|
||||
McpSchema.Resource.builder()
|
||||
.uri(resourceUri)
|
||||
.name("Subscribable Resource")
|
||||
.mimeType("text/plain")
|
||||
.build(),
|
||||
(exchange, req) -> new McpSchema.ReadResourceResult(
|
||||
List.of(new McpSchema.TextResourceContents(resourceUri, "text/plain", "initial content"))));
|
||||
|
||||
McpSyncServer mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build())
|
||||
.resources(resourceSpec)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> {
|
||||
receivedContents.set(contents);
|
||||
latch.countDown();
|
||||
}).build()) {
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
mcpClient.subscribeResource(new McpSchema.SubscribeRequest(resourceUri));
|
||||
|
||||
mcpServer.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(resourceUri));
|
||||
|
||||
assertThat(latch.await(5, TimeUnit.SECONDS))
|
||||
.as("client should receive the resources/updated notification within 5 seconds")
|
||||
.isTrue();
|
||||
assertThat(receivedContents.get()).isNotEmpty();
|
||||
}
|
||||
finally {
|
||||
mcpServer.closeGracefully();
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@MethodSource("clientsForTesting")
|
||||
void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) throws InterruptedException {
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
String resourceUri = "test://subscribable-resource-unsub";
|
||||
var notificationCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||
|
||||
McpServerFeatures.SyncResourceSpecification resourceSpec = new McpServerFeatures.SyncResourceSpecification(
|
||||
McpSchema.Resource.builder()
|
||||
.uri(resourceUri)
|
||||
.name("Subscribable Resource")
|
||||
.mimeType("text/plain")
|
||||
.build(),
|
||||
(exchange, req) -> new McpSchema.ReadResourceResult(
|
||||
List.of(new McpSchema.TextResourceContents(resourceUri, "text/plain", "content"))));
|
||||
|
||||
McpSyncServer mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build())
|
||||
.resources(resourceSpec)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet())
|
||||
.build()) {
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
mcpClient.subscribeResource(new McpSchema.SubscribeRequest(resourceUri));
|
||||
mcpClient.unsubscribeResource(new McpSchema.UnsubscribeRequest(resourceUri));
|
||||
|
||||
mcpServer.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(resourceUri));
|
||||
|
||||
assertThat(notificationCount.get()).as("no notification should be received after unsubscribing").isZero();
|
||||
}
|
||||
finally {
|
||||
mcpServer.closeGracefully();
|
||||
}
|
||||
}
|
||||
|
||||
private double evaluateExpression(String expression) {
|
||||
// Simple expression evaluator for testing
|
||||
return switch (expression) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package io.modelcontextprotocol;
|
|||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ public class MockMcpServerTransportProvider implements McpServerTransportProvide
|
|||
return session.sendNotification(method, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> notifyClient(String sessionId, String method, Object params) {
|
||||
if (session != null && session.getId().equals(sessionId)) {
|
||||
return session.sendNotification(method, params);
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return session.closeGracefully();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.modelcontextprotocol.MockMcpServerTransport;
|
||||
import io.modelcontextprotocol.MockMcpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for resource subscription logic in {@link McpAsyncServer}. Uses
|
||||
* {@link MockMcpServerTransportProvider} to drive sessions directly without a real
|
||||
* network stack.
|
||||
*/
|
||||
class ResourceSubscriptionTests {
|
||||
|
||||
private static final String RESOURCE_URI = "test://resource/1";
|
||||
|
||||
private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0");
|
||||
|
||||
private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0");
|
||||
|
||||
private static McpAsyncServer buildServer(MockMcpServerTransportProvider transportProvider) {
|
||||
return McpServer.async(transportProvider)
|
||||
.serverInfo(SERVER_INFO)
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static McpSchema.JSONRPCRequest initRequest() {
|
||||
return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE,
|
||||
UUID.randomUUID().toString(),
|
||||
new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, null, CLIENT_INFO));
|
||||
}
|
||||
|
||||
private static McpSchema.JSONRPCNotification initializedNotification() {
|
||||
return new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_NOTIFICATION_INITIALIZED,
|
||||
null);
|
||||
}
|
||||
|
||||
private static McpSchema.JSONRPCRequest subscribeRequest(String uri) {
|
||||
return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_RESOURCES_SUBSCRIBE,
|
||||
UUID.randomUUID().toString(), new McpSchema.SubscribeRequest(uri));
|
||||
}
|
||||
|
||||
private static McpSchema.JSONRPCRequest unsubscribeRequest(String uri) {
|
||||
return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_RESOURCES_UNSUBSCRIBE,
|
||||
UUID.randomUUID().toString(), new McpSchema.UnsubscribeRequest(uri));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyResourcesUpdated_noSubscribers_completesEmpty() {
|
||||
MockMcpServerTransport transport = new MockMcpServerTransport();
|
||||
MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport);
|
||||
McpAsyncServer server = buildServer(transportProvider);
|
||||
|
||||
transportProvider.simulateIncomingMessage(initRequest());
|
||||
transportProvider.simulateIncomingMessage(initializedNotification());
|
||||
transport.clearSentMessages();
|
||||
|
||||
StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI)))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(transport.getAllSentMessages()).as("no notification should be sent when nobody is subscribed")
|
||||
.isEmpty();
|
||||
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyResourcesUpdated_afterSubscribe_notifiesSession() {
|
||||
MockMcpServerTransport transport = new MockMcpServerTransport();
|
||||
MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport);
|
||||
McpAsyncServer server = buildServer(transportProvider);
|
||||
|
||||
transportProvider.simulateIncomingMessage(initRequest());
|
||||
transportProvider.simulateIncomingMessage(initializedNotification());
|
||||
transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI));
|
||||
transport.clearSentMessages();
|
||||
|
||||
StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI)))
|
||||
.verifyComplete();
|
||||
|
||||
McpSchema.JSONRPCMessage sent = transport.getLastSentMessage();
|
||||
assertThat(sent).isInstanceOf(McpSchema.JSONRPCNotification.class);
|
||||
McpSchema.JSONRPCNotification notification = (McpSchema.JSONRPCNotification) sent;
|
||||
assertThat(notification.method()).isEqualTo(McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED);
|
||||
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyResourcesUpdated_differentUri_doesNotNotifySession() {
|
||||
MockMcpServerTransport transport = new MockMcpServerTransport();
|
||||
MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport);
|
||||
McpAsyncServer server = buildServer(transportProvider);
|
||||
|
||||
transportProvider.simulateIncomingMessage(initRequest());
|
||||
transportProvider.simulateIncomingMessage(initializedNotification());
|
||||
transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI));
|
||||
transport.clearSentMessages();
|
||||
|
||||
StepVerifier
|
||||
.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification("test://other/resource")))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(transport.getAllSentMessages())
|
||||
.as("notification for a different URI should not reach a session subscribed to a different URI")
|
||||
.isEmpty();
|
||||
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyResourcesUpdated_afterUnsubscribe_doesNotNotifySession() {
|
||||
MockMcpServerTransport transport = new MockMcpServerTransport();
|
||||
MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport);
|
||||
McpAsyncServer server = buildServer(transportProvider);
|
||||
|
||||
transportProvider.simulateIncomingMessage(initRequest());
|
||||
transportProvider.simulateIncomingMessage(initializedNotification());
|
||||
transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI));
|
||||
transportProvider.simulateIncomingMessage(unsubscribeRequest(RESOURCE_URI));
|
||||
transport.clearSentMessages();
|
||||
|
||||
StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI)))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(transport.getAllSentMessages()).as("no notification should be sent after the session unsubscribed")
|
||||
.isEmpty();
|
||||
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyResourcesUpdated_afterSessionClose_doesNotNotifySession() {
|
||||
MockMcpServerTransport transport = new MockMcpServerTransport();
|
||||
MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport);
|
||||
McpAsyncServer server = buildServer(transportProvider);
|
||||
|
||||
transportProvider.simulateIncomingMessage(initRequest());
|
||||
transportProvider.simulateIncomingMessage(initializedNotification());
|
||||
transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI));
|
||||
|
||||
// Close the session; onClose must fire and remove the subscription
|
||||
transportProvider.closeGracefully().block();
|
||||
transport.clearSentMessages();
|
||||
|
||||
StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI)))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(transport.getAllSentMessages()).as("no notification should be sent after the session has closed")
|
||||
.isEmpty();
|
||||
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue