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
3 changed files with 506 additions and 60 deletions

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