-
Notifications
You must be signed in to change notification settings - Fork 23
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
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, | ||
} |
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])), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what happened to root a? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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