Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ public static final class Builder {

private McpChangePublisher changePublisher;

private SubscriptionRestriction subscriptionRestriction = (ctx, requested, honoured) -> Mono.just(honoured);

private Builder() {
}

Expand Down Expand Up @@ -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.
* <p>
* 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()}.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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}.
* <p>
* 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.
* <p>
* Return the subscriptions this caller may receive: either a narrowed {@code honoured},
* or everything the caller is allowed, whatever it requested. The server only keeps the
* part of the returned filter that it honours, so the restriction cannot grant
* notifications the client didn't request or the server cannot deliver. An empty
* {@code Mono} honours nothing.
* <p>
* 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.
* <p>
* 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:
* <pre>{@code
* (ctx, requested, honoured) -> {
* Mono<SubscriptionFilter> restricted = Mono.fromCallable(() -> {
* Principal principal = (Principal) ctx.transportContext().get("principal");
* List<String> 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());
* }
* }</pre>
*
* @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<SubscriptionFilter> restrict(McpRequestContext ctx, SubscriptionFilter requested, SubscriptionFilter honoured);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -55,6 +56,8 @@ final class SubscriptionsFeature implements McpFeature {

private final List<McpFeature> features;

private final SubscriptionRestriction restriction;

private final boolean hasTools;

private final boolean hasPrompts;
Expand All @@ -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<McpFeature> 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;
Expand Down Expand Up @@ -114,7 +121,8 @@ public Mono<McpAsyncResponse<Result>> 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
Expand Down Expand Up @@ -189,6 +197,47 @@ 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.
*/
private Mono<SubscriptionFilter> restricted(McpRequestContext ctx, SubscriptionFilter requested,
SubscriptionFilter honoured) {
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 flag is kept if both set it to
* {@code true}, a list keeps the elements of {@code honoured}'s list that
* {@code restricted}'s list contains, and any other value is kept if both have it.
*/
private static SubscriptionFilter intersection(SubscriptionFilter honoured, SubscriptionFilter restricted) {
SubscriptionFilter widened = restricted.missingFrom(honoured);
if (!widened.isEmpty()) {
logger.warn("Ignoring subscriptions the restriction added beyond what the server honours: {}", widened);
}
Map<String, Object> 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)) {
// Covers flags too: honoured only ever sets them to true.
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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
*/
Expand Down
Loading
Loading