Skip to content

Add a RootsTrackingSupport mixin for servers, use it from DartAnalyzerSupport #85

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 25, 2025
Merged
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
2 changes: 2 additions & 0 deletions pkgs/dart_mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- Automatically disconnect from servers if version negotiation fails.
- Added support for adding and listing `ResourceTemplate`s.
- Handlers have to handle their own matching of templates.
- Added a `RootsTrackingSupport` server mixin which can be used to keep an
updated list of the roots set by the client.
- **Breaking**: Fixed paginated result subtypes to use `nextCursor` instead of
`cursor` as the key for the next cursor.
- **Breaking**: Change the `ProgressNotification.progress` and
Expand Down
108 changes: 108 additions & 0 deletions pkgs/dart_mcp/lib/src/server/roots_tracking_support.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2025, 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';

/// Mix this in to any MCPServer to add support tracking the [Root]s as given
/// by the client in an opinionated way.
///
/// Listens to change events and updates the set of [roots].
base mixin RootsTrackingSupport on LoggingSupport {
/// All known workspace [Root]s from the last call to [listRoots].
///
/// May be a [Future] if we are currently requesting the roots.
FutureOr<List<Root>> get roots => switch (_rootsState) {
_RootsState.upToDate => _roots!,
_RootsState.pending => _rootsCompleter!.future,
};

/// The current state of [roots], whether it is up to date or waiting on
/// updated values.
_RootsState _rootsState = _RootsState.pending;

/// The list of [roots] if [_rootsState] is [_RootsState.upToDate],
/// otherwise `null`.
List<Root>? _roots;

/// Completer for any pending [listRoots] call if [_rootsState] is
/// [_RootsState.pending], otherwise `null`.
Completer<List<Root>>? _rootsCompleter = Completer();

/// Whether or not the connected client supports [listRoots].
///
/// Only safe to call after calling [initialize] on `super` since this is
/// based on the client capabilities.
bool get supportsRoots => clientCapabilities.roots != null;

/// Whether or not the connected client supports reporting changes to the
/// list of roots.
///
/// Only safe to call after calling [initialize] on `super` since this is
/// based on the client capabilities.
bool get supportsRootsChanged =>
clientCapabilities.roots?.listChanged == true;

@override
FutureOr<InitializeResult> initialize(InitializeRequest request) {
initialized.then((_) async {
if (!supportsRoots) {
log(
LoggingLevel.warning,
'Client does not support the roots capability, some functionality '
'may be disabled.',
);
} else {
if (supportsRootsChanged) {
rootsListChanged!.listen((event) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double checking: does this listener need to be cancelled anywhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller for this stream gets cancelled when the client disconnects so no it shouldn't be necessary

updateRoots();
});
}
await updateRoots();
}
});
return super.initialize(request);
}

/// Updates the list of [roots] by calling [listRoots].
///
/// If the current [_rootsCompleter] was not yet completed, then we wait to
/// complete it until we get an updated list of roots, so that we don't get
/// stale results from [listRoots] requests that are still in flight during
/// a change notification.
@mustCallSuper
Future<void> updateRoots() async {
_rootsState = _RootsState.pending;
final previousCompleter = _rootsCompleter;

// Always create a new completer so we can handle race conditions by
// checking completer identity.
final newCompleter = _rootsCompleter = Completer();
_roots = null;

if (previousCompleter != null) {
// Complete previously scheduled completers with our completers value.
previousCompleter.complete(newCompleter.future);
}

final result = await listRoots(ListRootsRequest());

// Only complete the completer if it's still the one we created. Otherwise
// we wait for the next result to come back and throw away this result.
if (_rootsCompleter == newCompleter) {
newCompleter.complete(result.roots);
_roots = result.roots;
_rootsCompleter = null;
_rootsState = _RootsState.upToDate;
}
}
}

/// The current state of the roots information.
enum _RootsState {
/// No change notification since our last update.
upToDate,

/// Waiting for a `listRoots` response.
pending,
}
1 change: 1 addition & 0 deletions pkgs/dart_mcp/lib/src/server/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ part 'completions_support.dart';
part 'logging_support.dart';
part 'prompts_support.dart';
part 'resources_support.dart';
part 'roots_tracking_support.dart';
part 'tools_support.dart';

/// Base class to extend when implementing an MCP server.
Expand Down
76 changes: 76 additions & 0 deletions pkgs/dart_mcp/test/roots_tracking_support_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2025, 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.
import 'dart:async';

import 'package:dart_mcp/client.dart';
import 'package:dart_mcp/server.dart';
import 'package:test/test.dart';

import 'test_utils.dart';

void main() {
test('server can track the workspace roots if enabled', () async {
final environment = TestEnvironment(
TestMCPClientWithRoots(),
(c) => TestMCPServerWithRootsTracking(channel: c),
);
await environment.initializeServer();

final client = environment.client;
final server = environment.server;

final a = Root(uri: 'test://a', name: 'a');
final b = Root(uri: 'test://b', name: 'b');

/// Basic interactions, add and remove some roots.
expect(await server.roots, isEmpty);
expect(client.addRoot(a), isTrue);
await pumpEventQueue();
expect(await server.roots, [a]);
expect(client.addRoot(b), isTrue);
await pumpEventQueue();
expect(await server.roots, unorderedEquals([a, b]));

final completer = Completer<void>();
client.waitToRespond = completer.future;
final c = Root(uri: 'test://c', name: 'c');
final d = Root(uri: 'test://d', name: 'd');
expect(client.addRoot(c), isTrue);
await pumpEventQueue();
expect(
server.roots,
isA<Future>(),
reason: 'Server is waiting to fetch new roots',
);
expect(
server.roots,
completion(unorderedEquals([b, c, d])),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happened to root a?

Copy link
Contributor Author

@jakemac53 jakemac53 Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It gets removed later on line 53 - this is an async expectation for a future that doesn't complete until the completer gets completed on line 55.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically the point of this is to test that if we get a roots changed event while we are currently listing roots, we won't ever surface the intermediate state, we will wait for the nest listRoots call.

reason: 'Should not see intermediate states',
);
expect(client.addRoot(d), isTrue);
await pumpEventQueue();
expect(client.removeRoot(a), isTrue);
await pumpEventQueue();
completer.complete();
client.waitToRespond = null;
expect(await server.roots, unorderedEquals([b, c, d]));
});
}

final class TestMCPClientWithRoots extends TestMCPClient with RootsSupport {
// Tests can assign this to delay responses to list root requests until it
// completes.
Future<void>? waitToRespond;

@override
FutureOr<ListRootsResult> handleListRoots(ListRootsRequest request) async {
await waitToRespond;
return super.handleListRoots(request);
}
}

final class TestMCPServerWithRootsTracking extends TestMCPServer
with LoggingSupport, RootsTrackingSupport {
TestMCPServerWithRootsTracking({required super.channel});
}
123 changes: 67 additions & 56 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import '../lsp/wire_format.dart';
///
/// The MCPServer must already have the [ToolsSupport] and [LoggingSupport]
/// mixins applied.
base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport {
base mixin DartAnalyzerSupport
on ToolsSupport, LoggingSupport, RootsTrackingSupport {
/// The LSP server connection for the analysis server.
late final Peer _lspConnection;

Expand All @@ -32,47 +33,51 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport {
/// is over.
Completer<void>? _doneAnalyzing = Completer();

/// All known workspace roots.
///
/// Identity is controlled by the [Root.uri].
Set<Root> workspaceRoots = HashSet(
equals: (r1, r2) => r2.uri == r2.uri,
hashCode: (r) => r.uri.hashCode,
);
/// The current LSP workspace folder state.
HashSet<lsp.WorkspaceFolder> _currentWorkspaceFolders =
HashSet<lsp.WorkspaceFolder>(
equals: (a, b) => a.uri == b.uri,
hashCode: (a) => a.uri.hashCode,
);

@override
FutureOr<InitializeResult> initialize(InitializeRequest request) async {
// This should come first, assigns `clientCapabilities`.
final result = await super.initialize(request);

// We check for requirements and store a message to log after initialization
// if some requirement isn't satisfied.
var unsupportedReason =
request.capabilities.roots == null
? 'Project analysis requires the "roots" capability which is not '
'supported. Analysis tools have been disabled.'
: (Platform.environment['DART_SDK'] == null
? 'Project analysis requires a "DART_SDK" environment variable '
'to be set (this should be the path to the root of the '
'dart SDK). Analysis tools have been disabled.'
: null);

unsupportedReason ??= await _initializeAnalyzerLspServer();
if (unsupportedReason == null) {
final unsupportedReasons = <String>[
if (!supportsRoots)
'Project analysis requires the "roots" capability which is not '
'supported. Analysis tools have been disabled.',
if (Platform.environment['DART_SDK'] == null)
'Project analysis requires a "DART_SDK" environment variable to be set '
'(this should be the path to the root of the dart SDK). Analysis '
'tools have been disabled.',
];

if (unsupportedReasons.isEmpty) {
if (await _initializeAnalyzerLspServer() case final failedReason?) {
unsupportedReasons.add(failedReason);
}
}

if (unsupportedReasons.isEmpty) {
registerTool(analyzeFilesTool, _analyzeFiles);
}

// Don't call any methods on the client until we are fully initialized
// (even logging).
unawaited(
initialized.then((_) {
if (unsupportedReason != null) {
log(LoggingLevel.warning, unsupportedReason);
} else {
// All requirements satisfied, ask the client for its roots.
_listenForRoots();
if (unsupportedReasons.isNotEmpty) {
log(LoggingLevel.warning, unsupportedReasons.join('\n'));
}
}),
);

return super.initialize(request);
return result;
}

/// Initializes the analyzer lsp server.
Expand Down Expand Up @@ -227,41 +232,47 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport {
});
}

/// Lists the roots, and listens for changes to them.
///
/// Sends workspace change notifications to the LSP server based on the roots.
void _listenForRoots() async {
rootsListChanged!.listen((event) async {
await _updateRoots();
});
await _updateRoots();
}
/// Update the LSP workspace dirs when our workspace [Root]s change.
@override
Future<void> updateRoots() async {
await super.updateRoots();
unawaited(() async {
final newRoots = await roots;

/// Updates the set of [workspaceRoots] and notifies the server.
Future<void> _updateRoots() async {
final newRoots = HashSet<Root>(
equals: (r1, r2) => r1.uri == r2.uri,
hashCode: (r) => r.uri.hashCode,
)..addAll((await listRoots(ListRootsRequest())).roots);
final oldWorkspaceFolders = _currentWorkspaceFolders;
final newWorkspaceFolders =
_currentWorkspaceFolders = HashSet<lsp.WorkspaceFolder>(
equals: (a, b) => a.uri == b.uri,
hashCode: (a) => a.uri.hashCode,
)..addAll(newRoots.map((r) => r.asWorkspaceFolder));

final removed = workspaceRoots.difference(newRoots);
final added = newRoots.difference(workspaceRoots);
workspaceRoots = newRoots;
final added =
newWorkspaceFolders.difference(oldWorkspaceFolders).toList();
final removed =
oldWorkspaceFolders.difference(newWorkspaceFolders).toList();

final event = lsp.WorkspaceFoldersChangeEvent(
added: [for (var root in added) root.asWorkspaceFolder],
removed: [for (var root in removed) root.asWorkspaceFolder],
);
// This can happen in the case of multiple notifications in quick
// succession, the `roots` future will complete only after the state has
// stabilized which can result in empty diffs.
if (added.isEmpty && removed.isEmpty) {
return;
}

log(
LoggingLevel.debug,
() => 'Notifying of workspace root change: ${event.toJson()}',
);
final event = lsp.WorkspaceFoldersChangeEvent(
added: added,
removed: removed,
);

_lspConnection.sendNotification(
lsp.Method.workspace_didChangeWorkspaceFolders.toString(),
lsp.DidChangeWorkspaceFoldersParams(event: event).toJson(),
);
log(
LoggingLevel.debug,
() => 'Notifying of workspace root change: ${event.toJson()}',
);

_lspConnection.sendNotification(
lsp.Method.workspace_didChangeWorkspaceFolders.toString(),
lsp.DidChangeWorkspaceFoldersParams(event: event).toJson(),
);
}());
}

@visibleForTesting
Expand Down
5 changes: 0 additions & 5 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
implements ProcessManagerSupport {
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) {
if (request.capabilities.roots == null) {
throw StateError(
'This server requires the "roots" capability to be implemented.',
);
}
registerTool(dartFixTool, _runDartFixTool);
registerTool(dartFormatTool, _runDartFormatTool);
registerTool(dartPubTool, _runDartPubTool);
Expand Down
1 change: 1 addition & 0 deletions pkgs/dart_tooling_mcp_server/lib/src/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ final class DartToolingMCPServer extends MCPServer
with
LoggingSupport,
ToolsSupport,
RootsTrackingSupport,
DartAnalyzerSupport,
DartCliSupport,
DartToolingDaemonSupport
Expand Down
Loading