diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java index a3f5aeab8..5c13221f9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/McpServer.java @@ -382,6 +382,8 @@ public static final class Builder { private McpChangePublisher changePublisher; + private SubscriptionRestriction subscriptionRestriction = (ctx, requested, honoured) -> Mono.just(honoured); + private Builder() { } @@ -428,6 +430,20 @@ public Builder subscriptions(McpChangePublisher publisher) { return this; } + /** + * Narrows, per request, the notifications {@code subscriptions/listen} agrees to + * honour, for example to keep a caller from watching another principal's + * resources. Only applies when {@link #subscriptions} is registered. Defaults to + * honouring everything the server supports. + *

+ * The restriction can only narrow the default honoured subscriptions. + */ + public Builder restrictSubscriptions(SubscriptionRestriction restriction) { + Assert.notNull(restriction, "restriction must not be null"); + this.subscriptionRestriction = restriction; + return this; + } + /** * The codec used to seal/open MRTR {@code requestState}. Defaults to * {@link HmacRequestStateCodec#builder()}{@code .build()}. @@ -479,7 +495,7 @@ public McpServer build() { SubscriptionsFeature subscriptionsFeature = null; if (this.changePublisher != null) { subscriptionsFeature = new SubscriptionsFeature(this.changePublisher, mapper, allFeatures, - capabilitiesBuilder.hasTools(), capabilitiesBuilder.hasPrompts(), + this.subscriptionRestriction, capabilitiesBuilder.hasTools(), capabilitiesBuilder.hasPrompts(), capabilitiesBuilder.hasResources(), capabilitiesBuilder.hasResourcesSubscribe()); subscriptionsFeature.capabilities(capabilitiesBuilder); allFeatures.add(subscriptionsFeature); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionRestriction.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionRestriction.java new file mode 100644 index 000000000..b85e442eb --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionRestriction.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.modern.server; + +import io.modelcontextprotocol.modern.McpException; +import io.modelcontextprotocol.modern.McpSchema.SubscriptionFilter; +import reactor.core.publisher.Mono; + +/** + * Narrows, per {@code subscriptions/listen} request, the notifications the server agrees + * to honour, for example to keep a caller from watching resources that belong to another + * principal. Registered through {@link McpServer.Builder#restrictSubscriptions}. + *

+ * Change notifications are server-wide: a {@code notifications/resources/updated} for a + * URI reaches every stream whose acknowledged filter contains that URI, whoever opened + * it. Without a restriction, any caller may subscribe to any URI and learn when that + * resource changes, though not its content. + *

+ * Return the part of {@code honoured} this caller may receive, typically {@code honoured} + * with some fields removed or some list elements left out. The restriction can only + * narrow: the server keeps only the part of the returned filter that it honours, so it + * cannot grant notifications the client didn't request or the server cannot deliver. + * Anything returned beyond {@code honoured} is ignored and logged as a warning. An empty + * {@code Mono} honours nothing. + *

+ * The restriction runs once, when the stream opens, and the filter it leaves is + * acknowledged to the client and applies for the whole life of the stream. A change in + * what the caller may receive, such as a revoked permission, isn't applied to streams + * that are already open: it takes effect on the caller's next + * {@code subscriptions/listen}. When the server honours nothing of the request, the + * restriction isn't called: the stream opens with an empty acknowledgement and delivers + * nothing, so there is nothing to narrow or refuse. + *

+ * To refuse the request altogether, return {@link Mono#error} or throw an + * {@link McpException}; it is answered with its error, before any stream opens. The spec + * defines no permission error code, so use for example + * {@code new McpException(ErrorCodes.INVALID_REQUEST, "Not permitted")}, with the same + * message whichever subscription is denied, so that the error does not tell the caller + * which URI it may not access. Any other exception is answered as an internal error. + *

+ * The restriction runs while the request is being handled, so it must not block the + * calling thread. Run blocking work, such as a permission lookup, on a scheduler that + * allows it, except where the request is already handled on a thread that may block: + *

{@code
+ * (ctx, requested, honoured) -> {
+ *     Mono restricted = Mono.fromCallable(() -> {
+ *         Principal principal = (Principal) ctx.transportContext().get("principal");
+ *         List allowed = honoured.resourceSubscriptionsOrEmpty()
+ *             .stream()
+ *             .filter(uri -> permissions.canWatch(principal, uri)) // blocking
+ *             .toList();
+ *         return honoured.with(SubscriptionFilter.RESOURCE_SUBSCRIPTIONS,
+ *                 allowed.isEmpty() ? null : allowed);
+ *     });
+ *     return ctx.isBlocking() ? restricted : restricted.subscribeOn(Schedulers.boundedElastic());
+ * }
+ * }
+ * + * @author Daniel Garnier-Moiroux + */ +@FunctionalInterface +public interface SubscriptionRestriction { + + /** + * The subscriptions this caller may receive. + * @param ctx the {@code subscriptions/listen} request, including the transport + * context that identifies the caller + * @param requested the filter the client sent + * @param honoured the part of {@code requested} the server and its features agree to + * honour + * @return the subscriptions this caller may receive, of which the server keeps the + * part it honours + */ + Mono restrict(McpRequestContext ctx, SubscriptionFilter requested, SubscriptionFilter honoured); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java index d0337f390..cd1ce01e9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/SubscriptionsFeature.java @@ -34,7 +34,8 @@ * requested filter, then forwards the changes from a {@link McpChangePublisher} that the * honoured filter covers. The core fields are honoured for the primitives the server has; * other features honour the fields they define through - * {@link McpFeature#honouredSubscriptions}. + * {@link McpFeature#honouredSubscriptions}. A {@link SubscriptionRestriction} then + * narrows the result per request. * * @author Dariusz Jędrzejczyk */ @@ -55,6 +56,8 @@ final class SubscriptionsFeature implements McpFeature { private final List features; + private final SubscriptionRestriction restriction; + private final boolean hasTools; private final boolean hasPrompts; @@ -67,13 +70,17 @@ final class SubscriptionsFeature implements McpFeature { /** * @param features the server's other features, asked for the fields they honour + * @param restriction narrows what the server and its features honour, per request */ SubscriptionsFeature(McpChangePublisher publisher, McpJsonMapper jsonMapper, List features, - boolean hasTools, boolean hasPrompts, boolean hasResources, boolean hasResourcesSubscribe) { + SubscriptionRestriction restriction, boolean hasTools, boolean hasPrompts, boolean hasResources, + boolean hasResourcesSubscribe) { Assert.notNull(publisher, "publisher must not be null"); + Assert.notNull(restriction, "restriction must not be null"); this.publisher = publisher; this.jsonMapper = jsonMapper; this.features = List.copyOf(features); + this.restriction = restriction; this.hasTools = hasTools; this.hasPrompts = hasPrompts; this.hasResources = hasResources; @@ -114,7 +121,8 @@ public Mono> handle(McpRequestContext ctx, Object param catch (McpException ex) { return Mono.error(ex); } - return Mono.just(McpAsyncResponse.streaming(notifier -> listen(ctx, honoured, notifier))); + return restricted(ctx, requested, honoured) + .map(agreed -> McpAsyncResponse.streaming(notifier -> listen(ctx, agreed, notifier))); } @Override @@ -189,6 +197,54 @@ private SubscriptionFilter honoured(McpRequestContext ctx, SubscriptionFilter re return SubscriptionFilter.of(honoured); } + /** + * What the restriction leaves of {@code honoured}. Deferred so that a restriction + * throwing instead of returning an error {@code Mono} still rejects the request. Not + * called when nothing is honoured: it could only narrow to nothing, and may do costly + * work such as a permission lookup. + */ + private Mono restricted(McpRequestContext ctx, SubscriptionFilter requested, + SubscriptionFilter honoured) { + if (honoured.isEmpty()) { + return Mono.just(SubscriptionFilter.EMPTY); + } + return Mono.defer(() -> this.restriction.restrict(ctx, requested, honoured)) + .defaultIfEmpty(SubscriptionFilter.EMPTY) + .map(restricted -> intersection(honoured, restricted)); + } + + /** + * The part of {@code restricted} that {@code honoured} covers: the restriction may + * only narrow, so that the stream never acknowledges a notification the client didn't + * request or that the server can't deliver. A list keeps the elements of + * {@code honoured}'s list that {@code restricted}'s list contains, and any other + * value, flags included, is kept if {@code restricted} has the same value. + */ + private static SubscriptionFilter intersection(SubscriptionFilter honoured, SubscriptionFilter restricted) { + if (honoured == restricted) { + return restricted; + } + SubscriptionFilter widened = restricted.missingFrom(honoured); + if (!widened.isEmpty()) { + logger.warn("Ignoring subscriptions the restriction added beyond what the server honours: {}", widened); + } + Map kept = new LinkedHashMap<>(); + honoured.fields().forEach((field, value) -> { + Object agreed = restricted.get(field); + if (value instanceof List elements) { + List agreedElements = agreed instanceof List list ? list : List.of(); + List both = elements.stream().filter(agreedElements::contains).toList(); + if (!both.isEmpty()) { + kept.put(field, both); + } + } + else if (value.equals(agreed)) { + kept.put(field, value); + } + }); + return SubscriptionFilter.of(kept); + } + /** * The params of the notification for {@code change}, with the stream's {@code _meta}, * or {@code null} if they aren't an object. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java index 5e63e36a4..cd49c6714 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/modern/server/feature/McpChangeFeed.java @@ -12,6 +12,11 @@ /** * An {@link McpChangeBroadcaster} and the {@link McpChangePublisher} its changes reach. A * change broadcast while no listen stream is active is dropped. + *

+ * Changes are typically consumed in an + * {@link io.modelcontextprotocol.modern.server.McpServer}, and broadcast to clients. They + * are filtered by client request and further restricted through + * {@link io.modelcontextprotocol.modern.server.SubscriptionRestriction}. * * @author Dariusz Jędrzejczyk */ diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java index c8434597b..6048033a9 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/modern/server/SubscriptionsFeatureTests.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.modern.server; import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,6 +50,7 @@ import static io.modelcontextprotocol.modern.server.ModernTestFixtures.respond; import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.MAP; import static org.assertj.core.api.InstanceOfAssertFactories.STRING; import static org.assertj.core.api.InstanceOfAssertFactories.type; @@ -339,7 +341,177 @@ void changeDeliveredBeforeAckIsSentFollowsTheAck() { .verify(Duration.ofSeconds(5)); } + @Test + void restrictionNarrowsResourceSubscriptionsAndOnlyThoseAreForwarded() { + McpChangeFeed feed = new McpChangeFeed(); + McpServer server = resourcesServerBuilder(true).subscriptions(feed) + .restrictSubscriptions((ctx, requested, honoured) -> Mono + .just(honoured.with(SubscriptionFilter.RESOURCE_SUBSCRIPTIONS, List.of("test://mine")))) + .build(); + + var listen = (McpTransportResponse.Streaming) listenWith(server, + Map.of("resourceSubscriptions", List.of("test://mine", "test://theirs"))); + + StepVerifier.create(listen.messages()) + .assertNext(msg -> assertThat( + acknowledgedNotifications((JSONRPCNotification) msg).resourceSubscriptionsOrEmpty()) + .containsExactly("test://mine")) + .then(() -> { + feed.broadcast(new ServerChange.ResourceUpdated("test://theirs")); + feed.broadcast(new ServerChange.ResourceUpdated("test://mine")); + }) + .assertNext(msg -> assertThat(((JSONRPCNotification) msg).params()).asInstanceOf(MAP) + .containsEntry("uri", "test://mine")) + .then(server::closeGracefully) + .expectNextMatches(JSONRPCResponse.class::isInstance) + .verifyComplete(); + } + + @Test + void restrictionSeesTheCallerTheRequestedFilterAndWhatTheServerHonours() { + List seen = new ArrayList<>(); + McpServer server = resourcesServerBuilder(true).subscriptions(new McpChangeFeed()) + .restrictSubscriptions((ctx, requested, honoured) -> { + seen.add(ctx.transportContext().get("principal")); + seen.add(requested); + seen.add(honoured); + return Mono.just(honoured); + }) + .build(); + + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", Map.of("resourceSubscriptions", List.of("test://a"), "toolsListChanged", true)); + server + .handle(McpTransportContext.create(Map.of("principal", "alice")), + new JSONRPCRequest("subscriptions/listen", 1, params)) + .block(); + server.closeGracefully(); + + assertThat(seen).containsExactly("alice", + SubscriptionFilter.of(Map.of("resourceSubscriptions", List.of("test://a"), "toolsListChanged", true)), + SubscriptionFilter.of(Map.of("resourceSubscriptions", List.of("test://a")))); + } + + @Test + void restrictionCannotWidenWhatTheServerHonours() { + McpServer server = resourcesServerBuilder(true).subscriptions(new McpChangeFeed()) + .restrictSubscriptions((ctx, requested, honoured) -> Mono.just(SubscriptionFilter.of(Map.of( + // Requested and honoured, kept. + "resourcesListChanged", true, + // One requested and honoured URI, kept, and one never requested. + "resourceSubscriptions", List.of("test://a", "test://unrequested"), + // Requested, but the server has no tools to honour it for. + "toolsListChanged", true, + // Never requested. + "promptsListChanged", true, "unrequested", true)))) + .build(); + + var listen = (McpTransportResponse.Streaming) listenWith(server, Map.of("resourcesListChanged", true, + "resourceSubscriptions", List.of("test://a"), "toolsListChanged", true)); + + StepVerifier.create(listen.messages()) + .assertNext( + msg -> assertThat(acknowledgedNotifications((JSONRPCNotification) msg)).isEqualTo(SubscriptionFilter + .of(Map.of("resourcesListChanged", true, "resourceSubscriptions", List.of("test://a"))))) + .then(server::closeGracefully) + .expectNextMatches(JSONRPCResponse.class::isInstance) + .verifyComplete(); + } + + @Test + void restrictionCannotRestoreWhatAFeatureNarrowed() { + McpServer server = McpServer.builder() + .serverInfo(SERVER_INFO) + .feature(new TasksExtension(List.of("t-1"), false)) + .subscriptions(new McpChangeFeed()) + .restrictSubscriptions((ctx, requested, honoured) -> Mono.just(requested)) + .build(); + + var listen = (McpTransportResponse.Streaming) listenForTasks(server, Map.of("taskIds", List.of("t-1", "t-2"))); + + StepVerifier.create(listen.messages()) + .assertNext(msg -> assertThat(acknowledgedNotifications((JSONRPCNotification) msg)) + .isEqualTo(SubscriptionFilter.of(Map.of("taskIds", List.of("t-1"))))) + .then(server::closeGracefully) + .expectNextMatches(JSONRPCResponse.class::isInstance) + .verifyComplete(); + } + + @Test + void restrictionCompletingEmptyHonoursNothing() { + McpServer server = resourcesServerBuilder(true).subscriptions(new McpChangeFeed()) + .restrictSubscriptions((ctx, requested, honoured) -> Mono.empty()) + .build(); + + var listen = (McpTransportResponse.Streaming) listenWith(server, + Map.of("resourcesListChanged", true, "resourceSubscriptions", List.of("test://a"))); + + StepVerifier.create(listen.messages()) + .assertNext(msg -> assertThat(acknowledgedNotifications((JSONRPCNotification) msg)) + .isEqualTo(SubscriptionFilter.EMPTY)) + .then(server::closeGracefully) + .expectNextMatches(JSONRPCResponse.class::isInstance) + .verifyComplete(); + } + + @Test + void restrictionIsNotCalledWhenNothingIsHonoured() { + McpServer server = resourcesServerBuilder(true).subscriptions(new McpChangeFeed()) + .restrictSubscriptions((ctx, requested, honoured) -> { + throw new AssertionError("restriction called for " + honoured); + }) + .build(); + + // The server has no tools to honour toolsListChanged for. + var listen = (McpTransportResponse.Streaming) listenWith(server, Map.of("toolsListChanged", true)); + + StepVerifier.create(listen.messages()) + .assertNext(msg -> assertThat(acknowledgedNotifications((JSONRPCNotification) msg)) + .isEqualTo(SubscriptionFilter.EMPTY)) + .then(server::closeGracefully) + .expectNextMatches(JSONRPCResponse.class::isInstance) + .verifyComplete(); + } + + static Stream rejectingRestrictions() { + return Stream.of((ctx, requested, honoured) -> Mono.error(McpException.invalidParams("not permitted")), + (ctx, requested, honoured) -> { + throw McpException.invalidParams("not permitted"); + }); + } + + @ParameterizedTest + @MethodSource("rejectingRestrictions") + void restrictionRejectingTheFilterAnswersBeforeAnyStreamOpens(SubscriptionRestriction restriction) { + McpServer server = resourcesServerBuilder(true).subscriptions(new McpChangeFeed()) + .restrictSubscriptions(restriction) + .build(); + + McpTransportResponse response = listenWith(server, Map.of("resourceSubscriptions", List.of("test://a"))); + + assertThat(response).asInstanceOf(type(McpTransportResponse.Error.class)) + .extracting(error -> error.response().error()) + .returns(ErrorCodes.INVALID_PARAMS, JSONRPCError::code) + .returns("not permitted", JSONRPCError::message); + } + + private static McpTransportResponse listenWith(McpServer server, Map filter) { + Map params = new HashMap<>(); + params.put("_meta", meta()); + params.put("notifications", filter); + return server.handle(McpTransportContext.EMPTY, new JSONRPCRequest("subscriptions/listen", 1, params)).block(); + } + private static McpServer resourcesServer(boolean supportsSubscribe, McpChangePublisher publisher) { + McpServer.Builder builder = resourcesServerBuilder(supportsSubscribe); + if (publisher != null) { + builder.subscriptions(publisher); + } + return builder.build(); + } + + private static McpServer.Builder resourcesServerBuilder(boolean supportsSubscribe) { McpAsyncResourceRepository repository = new McpAsyncResourceRepository() { @Override public Mono list(McpRequestContext ctx, String cursor) { @@ -356,13 +528,9 @@ public boolean supportsSubscribe() { return supportsSubscribe; } }; - McpServer.Builder builder = McpServer.builder() + return McpServer.builder() .serverInfo(SERVER_INFO) .feature(ResourcesFeature.ofAsync(repository, McpJsonDefaults.getMapper(), 0L, CacheScope.PRIVATE)); - if (publisher != null) { - builder.subscriptions(publisher); - } - return builder.build(); } @SuppressWarnings("unchecked")