blob: a59e3080d5db6251be428ca5395511107144f6df [file]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
part of 'server.dart';
/// A mixin for MCP servers which serve `subscriptions/listen` requests.
///
/// Stamps the subscription ID on the acknowledgement and holds the request
/// until shutdown. A `package:json_rpc_2` handler does not receive that ID,
/// so a transport sets [nextSubscriptionId] before delivering the request.
/// [handleRequestScopedMessage] does. A request without one is refused.
///
/// See https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions.
base mixin SubscriptionsSupport on MCPServer {
/// Ends each open subscription, under the ID it was opened with.
final Map<RequestId, Completer<void>> _subscriptions = {};
/// The first [shutdown] call, which every later one waits on.
Completer<void>? _shutdown;
/// The ID the next `subscriptions/listen` request opens its subscription
/// under.
///
/// A handler cannot read the JSON-RPC ID of the request it answers, and a
/// subscription is named by that ID. The transport serving the request sets
/// this before delivering it. Leaving it `null` refuses the request.
RequestId? nextSubscriptionId;
/// Advertises every registered capability, since this server serves the
/// `subscriptions/listen` requests a client opens to hear those
/// notifications on. The copy keeps the advertisement off server state.
@override
ServerCapabilities get advertisedCapabilities =>
ServerCapabilities.fromMap({...capabilities as Map<String, Object?>});
@override
FutureOr<void> initialize(MCPServerInitialization initialization) async {
if (initialization.protocolVersion.methodIsValid(
SubscriptionsListenRequest.methodName,
)) {
registerRequestHandler(
SubscriptionsListenRequest.methodName,
handleSubscriptionsListen,
);
}
await super.initialize(initialization);
}
/// Ends every open subscription before closing the connection, so each of
/// their requests gets the response the specification asks a server tearing
/// a subscription down to send.
@override
Future<void> shutdown() async {
if (_shutdown case final pending?) return pending.future;
final shutdown = _shutdown = Completer<void>();
// A later caller awaits this and sees the error; on the first call the
// error also travels up the `rethrow` below, so nothing has to listen.
shutdown.future.ignore();
try {
final open = _subscriptions.values.toList();
_subscriptions.clear();
for (final subscription in open) {
if (!subscription.isCompleted) subscription.complete();
}
if (open.isNotEmpty && isActive) {
// `package:json_rpc_2` writes each response in a microtask once its
// handler returns, and drops it when the connection is already
// closed. An event loop turn runs every pending microtask, so the
// responses are out before `super.shutdown()` closes the connection.
await Future<void>.delayed(Duration.zero);
}
await super.shutdown();
shutdown.complete();
} catch (error, stackTrace) {
shutdown.completeError(error, stackTrace);
rethrow;
}
}
/// Acknowledges [request] and keeps it open until the server shuts down.
///
/// The acknowledgement and the result both carry the subscription ID under
/// `io.modelcontextprotocol/subscriptionId`. On a server with
/// [ResourcesSupport], every URI the acknowledged `resourceSubscriptions`
/// filter names starts sending [ResourceUpdatedNotification]s, so that
/// [ResourcesSupport.updateResource] reaches the client that asked for it.
///
/// Throws an [RpcException] with `-32602` if the filter is not the shape
/// the schema describes, and `-32600` if [nextSubscriptionId] is missing
/// or already names an open subscription.
FutureOr<SubscriptionsListenResult> handleSubscriptionsListen(
SubscriptionsListenRequest request,
) async {
final subscriptionId = nextSubscriptionId;
nextSubscriptionId = null;
final fields = request as Map<String, Object?>;
final notifications = fields[Keys.notifications];
if (notifications is! Map<String, Object?>) {
throw RpcException.invalidParams(
'The `${Keys.notifications}` field got `$notifications`, but must be '
'a JSON object.',
);
}
for (final key in [
Keys.toolsListChanged,
Keys.promptsListChanged,
Keys.resourcesListChanged,
]) {
final value = notifications[key];
if (notifications.containsKey(key) && value is! bool) {
throw RpcException.invalidParams(
'The `$key` filter got `$value`, but only allows `true`, `false`, '
'or omission.',
);
}
}
final resourceSubscriptions = notifications[Keys.resourceSubscriptions];
if (notifications.containsKey(Keys.resourceSubscriptions) &&
(resourceSubscriptions is! List ||
resourceSubscriptions.any((uri) => uri is! String))) {
throw RpcException.invalidParams(
'The `${Keys.resourceSubscriptions}` filter got '
'`$resourceSubscriptions`, but must be a list of string URIs.',
);
}
final requested = SubscriptionFilter.fromMap(notifications);
final requestedResources = (resourceSubscriptions as List?)
?.cast<String>()
.toList(growable: false);
final accepted = SubscriptionFilter(
toolsListChanged:
requested.toolsListChanged == true &&
capabilities.tools?.listChanged == true
? true
: null,
promptsListChanged:
requested.promptsListChanged == true &&
capabilities.prompts?.listChanged == true
? true
: null,
resourcesListChanged:
requested.resourcesListChanged == true &&
capabilities.resources?.listChanged == true
? true
: null,
resourceSubscriptions:
capabilities.resources?.subscribe == true &&
requestedResources?.isNotEmpty == true
? requestedResources
: null,
);
if (subscriptionId == null) {
throw RpcException(
error_code.INVALID_REQUEST,
'A `${SubscriptionsListenRequest.methodName}` subscription is named '
'by the JSON-RPC ID of the request which opens it, and this server '
'was given no ID to name this one by.',
);
}
if (_subscriptions.containsKey(subscriptionId)) {
throw RpcException(
error_code.INVALID_REQUEST,
'A `${SubscriptionsListenRequest.methodName}` subscription is already '
'open under this request ID.',
);
}
final subscriptionEnd = Completer<void>();
_subscriptions[subscriptionId] = subscriptionEnd;
try {
if (this case final ResourcesSupport resources) {
for (final uri in accepted.resourceSubscriptions ?? const <String>[]) {
resources._sendUpdatesFor(uri);
}
}
sendNotification(
SubscriptionsAcknowledgedNotification.methodName,
SubscriptionsAcknowledgedNotification(
notifications: accepted,
meta: MetaWithSubscriptionId(subscriptionId: subscriptionId),
),
);
await subscriptionEnd.future;
return SubscriptionsListenResult(
meta: MetaWithSubscriptionId(subscriptionId: subscriptionId),
);
} finally {
_subscriptions.remove(subscriptionId);
}
}
}