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:
Dariusz Jędrzejczyk 2026-03-02 15:26:03 +01:00 • committed by GitHub
commit 7f68ea5bc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 506 additions and 60 deletions

View file

@ -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) {

View file

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

View file

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

View file

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