Migrate to the connected app service (#208)
Closes https://github.com/dart-lang/ai/issues/198
This does drop all support for connecting to apps on SDKs that don't have this service, and I bumped the min SDK accordingly.
diff --git a/.github/workflows/dart_mcp_server.yaml b/.github/workflows/dart_mcp_server.yaml
index d7af221..0cb3a64 100644
--- a/.github/workflows/dart_mcp_server.yaml
+++ b/.github/workflows/dart_mcp_server.yaml
@@ -28,7 +28,7 @@
fail-fast: false
matrix:
flutterSdk:
- - stable
+ - beta
- master
os:
- ubuntu-latest
diff --git a/pkgs/dart_mcp_server/CHANGELOG.md b/pkgs/dart_mcp_server/CHANGELOG.md
index 635f677..4b9e419 100644
--- a/pkgs/dart_mcp_server/CHANGELOG.md
+++ b/pkgs/dart_mcp_server/CHANGELOG.md
@@ -42,6 +42,8 @@
* Add `--log-file` argument to log all protocol traffic to a file.
* Improve error text for failed DTD connections as well as the tool description.
* Add support for injecting an `Analytics` instance to track usage.
+* Listen to the new DTD `ConnectedApp` service instead of the `Editor.DebugSessions`
+ service, when available.
* Screenshot tool disabled until
https://github.com/flutter/flutter/issues/170357 is resolved.
* Add `arg_parser.dart` public library with minimal deps to be used by the dart tool.
diff --git a/pkgs/dart_mcp_server/lib/src/arg_parser.dart b/pkgs/dart_mcp_server/lib/src/arg_parser.dart
index 4dd55c3..ac02e11 100644
--- a/pkgs/dart_mcp_server/lib/src/arg_parser.dart
+++ b/pkgs/dart_mcp_server/lib/src/arg_parser.dart
@@ -4,38 +4,37 @@
import 'package:args/args.dart';
-final argParser =
- ArgParser(allowTrailingOptions: false)
- ..addOption(
- dartSdkOption,
- help:
- 'The path to the root of the desired Dart SDK. Defaults to the '
- 'DART_SDK environment variable.',
- )
- ..addOption(
- flutterSdkOption,
- help:
- 'The path to the root of the desired Flutter SDK. Defaults to '
- 'the FLUTTER_SDK environment variable, then searching up from '
- 'the Dart SDK.',
- )
- ..addFlag(
- forceRootsFallbackFlag,
- negatable: true,
- defaultsTo: false,
- help:
- 'Forces a behavior for project roots which uses MCP tools '
- 'instead of the native MCP roots. This can be helpful for '
- 'clients like cursor which claim to have roots support but do '
- 'not actually support it.',
- )
- ..addOption(
- logFileOption,
- help:
- 'Path to a file to log all MPC protocol traffic to. File will be '
- 'overwritten if it exists.',
- )
- ..addFlag(helpFlag, abbr: 'h', help: 'Show usage text');
+final argParser = ArgParser(allowTrailingOptions: false)
+ ..addOption(
+ dartSdkOption,
+ help:
+ 'The path to the root of the desired Dart SDK. Defaults to the '
+ 'DART_SDK environment variable.',
+ )
+ ..addOption(
+ flutterSdkOption,
+ help:
+ 'The path to the root of the desired Flutter SDK. Defaults to '
+ 'the FLUTTER_SDK environment variable, then searching up from '
+ 'the Dart SDK.',
+ )
+ ..addFlag(
+ forceRootsFallbackFlag,
+ negatable: true,
+ defaultsTo: false,
+ help:
+ 'Forces a behavior for project roots which uses MCP tools '
+ 'instead of the native MCP roots. This can be helpful for '
+ 'clients like cursor which claim to have roots support but do '
+ 'not actually support it.',
+ )
+ ..addOption(
+ logFileOption,
+ help:
+ 'Path to a file to log all MPC protocol traffic to. File will be '
+ 'overwritten if it exists.',
+ )
+ ..addFlag(helpFlag, abbr: 'h', help: 'Show usage text');
const dartSdkOption = 'dart-sdk';
const flutterSdkOption = 'flutter-sdk';
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart b/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart
index 52cf80d..eb0f2fe 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart
@@ -110,19 +110,18 @@
log(LoggingLevel.warning, line, logger: 'DartLanguageServer');
});
- final lspConnection =
- Peer(lspChannel(lspServer.stdout, lspServer.stdin))
- ..registerMethod(
- lsp.Method.textDocument_publishDiagnostics.toString(),
- _handleDiagnostics,
- )
- ..registerMethod(r'$/analyzerStatus', _handleAnalyzerStatus)
- ..registerFallback((Parameters params) {
- log(
- LoggingLevel.debug,
- () => 'Unhandled LSP message: ${params.method} - ${params.asMap}',
- );
- });
+ final lspConnection = Peer(lspChannel(lspServer.stdout, lspServer.stdin))
+ ..registerMethod(
+ lsp.Method.textDocument_publishDiagnostics.toString(),
+ _handleDiagnostics,
+ )
+ ..registerMethod(r'$/analyzerStatus', _handleAnalyzerStatus)
+ ..registerFallback((Parameters params) {
+ log(
+ LoggingLevel.debug,
+ () => 'Unhandled LSP message: ${params.method} - ${params.asMap}',
+ );
+ });
_lspConnection = lspConnection;
unawaited(lspConnection.listen());
@@ -357,8 +356,9 @@
diagnostics[diagnosticParams.uri] = diagnosticParams.diagnostics;
log(LoggingLevel.debug, {
ParameterNames.uri: diagnosticParams.uri,
- 'diagnostics':
- diagnosticParams.diagnostics.map((d) => d.toJson()).toList(),
+ 'diagnostics': diagnosticParams.diagnostics
+ .map((d) => d.toJson())
+ .toList(),
});
}
@@ -370,16 +370,18 @@
final newRoots = await roots;
final oldWorkspaceFolders = _currentWorkspaceFolders;
- final newWorkspaceFolders =
- _currentWorkspaceFolders = HashSet<lsp.WorkspaceFolder>(
+ 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 added =
- newWorkspaceFolders.difference(oldWorkspaceFolders).toList();
- final removed =
- oldWorkspaceFolders.difference(newWorkspaceFolders).toList();
+ final added = newWorkspaceFolders
+ .difference(oldWorkspaceFolders)
+ .toList();
+ final removed = oldWorkspaceFolders
+ .difference(newWorkspaceFolders)
+ .toList();
// This can happen in the case of multiple notifications in quick
// succession, the `roots` future will complete only after the state has
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart b/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
index ef4bb55..cf137ad 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
@@ -119,10 +119,9 @@
// Platforms are ignored for Dart, so no need to validate them.
final invalidPlatforms = platforms.difference(_allowedFlutterPlatforms);
if (invalidPlatforms.isNotEmpty) {
- final plural =
- invalidPlatforms.length > 1
- ? 'are not valid platforms'
- : 'is not a valid platform';
+ final plural = invalidPlatforms.length > 1
+ ? 'are not valid platforms'
+ : 'is not a valid platform';
errors.add(
ValidationError(
ValidationErrorType.custom,
@@ -163,14 +162,13 @@
return runCommandInRoot(
request,
arguments: commandArgs,
- commandForRoot:
- (_, _, sdk) =>
- switch (projectType) {
- 'dart' => sdk.dartExecutablePath,
- 'flutter' => sdk.flutterExecutablePath,
- _ => StateError('Unknown project type: $projectType'),
- }
- as String,
+ commandForRoot: (_, _, sdk) =>
+ switch (projectType) {
+ 'dart' => sdk.dartExecutablePath,
+ 'flutter' => sdk.flutterExecutablePath,
+ _ => StateError('Unknown project type: $projectType'),
+ }
+ as String,
commandDescription: '$projectType create',
fileSystem: fileSystem,
processManager: processManager,
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
index 580ce6b..591f3ab 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
@@ -8,7 +8,6 @@
import 'package:dart_mcp/server.dart';
import 'package:dds_service_extensions/dds_service_extensions.dart';
import 'package:dtd/dtd.dart';
-import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:meta/meta.dart';
import 'package:unified_analytics/unified_analytics.dart' as ua;
import 'package:vm_service/vm_service.dart';
@@ -28,21 +27,21 @@
implements AnalyticsSupport {
DartToolingDaemon? _dtd;
- /// Whether or not the DTD extension to get the active debug sessions is
- /// ready to be invoked.
- bool _getDebugSessionsReady = false;
-
/// The last reported active location from the editor.
Map<String, Object?>? _activeLocation;
- /// A Map of [VmService] object [Future]s by their associated
- /// [DebugSession.id].
+ /// A Map of [VmService] object [Future]s by their VM Service URI.
///
- /// [VmService] objects are automatically removed from the Map when the
- /// [DebugSession] shuts down.
+ /// [VmService] objects are automatically removed from the Map when they
+ /// are unregistered via DTD or when the VM service shuts down.
@visibleForTesting
final activeVmServices = <String, Future<VmService>>{};
+ /// Whether or not the connected app service is supported.
+ ///
+ /// Once we connect to dtd, this may be toggled to `true`.
+ bool _connectedAppServiceIsSupported = false;
+
/// Whether to await the disposal of all [VmService] objects in
/// [activeVmServices] upon server shutdown or loss of DTD connection.
///
@@ -67,8 +66,8 @@
/// Called when the DTD connection is lost, resets all associated state.
Future<void> _resetDtd() async {
_dtd = null;
- _getDebugSessionsReady = false;
_activeLocation = null;
+ _connectedAppServiceIsSupported = false;
// TODO: determine whether we need to dispose the [inspectorObjectGroup] on
// the Flutter Widget Inspector for each VM service instance.
@@ -87,75 +86,67 @@
Future<void> updateActiveVmServices() async {
final dtd = _dtd;
if (dtd == null) return;
+ if (!_connectedAppServiceIsSupported) return;
- // TODO: in the future, get the active VM service URIs from DTD directly
- // instead of from the `Editor.getDebugSessions` service method.
- if (!_getDebugSessionsReady) {
- // Give it a chance to get ready.
- await Future<void>.delayed(const Duration(seconds: 1));
- if (!_getDebugSessionsReady) return;
- }
+ final vmServiceInfos = (await dtd.getVmServices()).vmServicesInfos;
+ if (vmServiceInfos.isEmpty) return;
- final response = await dtd.getDebugSessions();
- final debugSessions = response.debugSessions;
- for (final debugSession in debugSessions) {
- if (activeVmServices.containsKey(debugSession.id)) {
+ for (final vmServiceInfo in vmServiceInfos) {
+ final vmServiceUri = vmServiceInfo.uri;
+ if (activeVmServices.containsKey(vmServiceUri)) {
continue;
}
- if (debugSession.vmServiceUri case final vmServiceUri?) {
- final vmServiceFuture =
- activeVmServices[debugSession.id] = vmServiceConnectUri(
- vmServiceUri,
- );
- final vmService = await vmServiceFuture;
- // Start listening for and collecting errors immediately.
- final errorService = await _AppErrorsListener.forVmService(
- vmService,
- this,
+ final vmServiceFuture = activeVmServices[vmServiceUri] =
+ vmServiceConnectUri(vmServiceUri);
+ final vmService = await vmServiceFuture;
+ // Start listening for and collecting errors immediately.
+ final errorService = await _AppErrorsListener.forVmService(
+ vmService,
+ this,
+ );
+ final resource = Resource(
+ uri: '$runtimeErrorsScheme://${vmService.id}',
+ name: 'Errors for app ${vmServiceInfo.name}',
+ description:
+ 'Recent runtime errors seen for app "${vmServiceInfo.name}".',
+ );
+ addResource(resource, (request) async {
+ final watch = Stopwatch()..start();
+ final result = ReadResourceResult(
+ contents: [
+ for (var error in errorService.errorLog.errors)
+ TextResourceContents(uri: resource.uri, text: error),
+ ],
);
- final resource = Resource(
- uri: '$runtimeErrorsScheme://${debugSession.id}',
- name: debugSession.name,
- description:
- 'Recent runtime errors seen for debug session '
- '"${debugSession.name}".',
- );
- addResource(resource, (request) async {
- final watch = Stopwatch()..start();
- final result = ReadResourceResult(
- contents: [
- for (var error in errorService.errorLog.errors)
- TextResourceContents(uri: resource.uri, text: error),
- ],
- );
- watch.stop();
- try {
- analytics?.send(
- ua.Event.dartMCPEvent(
- client: clientInfo.name,
- clientVersion: clientInfo.version,
- serverVersion: implementation.version,
- type: AnalyticsEvent.readResource.name,
- additionalData: ReadResourceMetrics(
- kind: ResourceKind.runtimeErrors,
- length: result.contents.length,
- elapsedMilliseconds: watch.elapsedMilliseconds,
- ),
+ watch.stop();
+ try {
+ analytics?.send(
+ ua.Event.dartMCPEvent(
+ client: clientInfo.name,
+ clientVersion: clientInfo.version,
+ serverVersion: implementation.version,
+ type: AnalyticsEvent.readResource.name,
+ additionalData: ReadResourceMetrics(
+ kind: ResourceKind.runtimeErrors,
+ length: result.contents.length,
+ elapsedMilliseconds: watch.elapsedMilliseconds,
),
- );
- } catch (e) {
- log(LoggingLevel.warning, 'Error sending analytics event: $e');
- }
- return result;
- });
+ ),
+ );
+ } catch (e) {
+ log(LoggingLevel.warning, 'Error sending analytics event: $e');
+ }
+ return result;
+ });
+ try {
errorService.errorsStream.listen((_) => updateResource(resource));
- unawaited(
- vmService.onDone.then((_) {
- removeResource(resource.uri);
- activeVmServices.remove(debugSession.id);
- }),
- );
- }
+ } catch (_) {}
+ unawaited(
+ vmService.onDone.then((_) {
+ removeResource(resource.uri);
+ activeVmServices.remove(vmServiceUri);
+ }),
+ );
}
}
@@ -217,51 +208,53 @@
}
}
- /// Listens to the `Service` and `Editor` streams so we know when the
- /// `Editor.getDebugSessions` extension method is registered and when debug
- /// sessions are started and stopped.
+ /// Listens to the `ConnectedApp` and `Editor` streams to get app and IDE
+ /// state information.
///
/// The dart tooling daemon must be connected prior to calling this function.
Future<void> _listenForServices() async {
final dtd = _dtd!;
- dtd.onEvent('Service').listen((e) async {
- log(
- LoggingLevel.debug,
- () => 'DTD Service event:\n${e.kind}: ${jsonEncode(e.data)}',
- );
+
+ _connectedAppServiceIsSupported = false;
+ try {
+ final registeredServices = await dtd.getRegisteredServices();
+ if (registeredServices.dtdServices.contains(
+ '${ConnectedAppServiceConstants.serviceName}.'
+ '${ConnectedAppServiceConstants.getVmServices}',
+ )) {
+ _connectedAppServiceIsSupported = true;
+ }
+ } catch (_) {}
+
+ if (_connectedAppServiceIsSupported) {
+ await _listenForConnectedAppServiceEvents();
+ }
+ await _listenForEditorEvents();
+ }
+
+ Future<void> _listenForConnectedAppServiceEvents() async {
+ final dtd = _dtd!;
+ dtd.onVmServiceUpdate().listen((e) async {
+ log(LoggingLevel.debug, e.toString());
switch (e.kind) {
- case 'ServiceRegistered':
- if (e.data['service'] == 'Editor' &&
- e.data['method'] == 'getDebugSessions') {
- log(
- LoggingLevel.debug,
- 'Editor.getDebugSessions registered, dtd is ready',
- );
- _getDebugSessionsReady = true;
- }
- case 'ServiceUnregistered':
- if (e.data['service'] == 'Editor' &&
- e.data['method'] == 'getDebugSessions') {
- log(
- LoggingLevel.debug,
- 'Editor.getDebugSessions unregistered, dtd is no longer ready',
- );
- _getDebugSessionsReady = false;
- }
+ case ConnectedAppServiceConstants.vmServiceRegistered:
+ await updateActiveVmServices();
+ case ConnectedAppServiceConstants.vmServiceUnregistered:
+ await activeVmServices
+ .remove(e.data['uri'] as String)
+ ?.then((service) => service.dispose());
+ default:
}
});
- await dtd.streamListen('Service');
+ await dtd.streamListen(ConnectedAppServiceConstants.serviceName);
+ }
+ /// Listens for editor specific events.
+ Future<void> _listenForEditorEvents() async {
+ final dtd = _dtd!;
dtd.onEvent('Editor').listen((e) async {
log(LoggingLevel.debug, e.toString());
switch (e.kind) {
- case 'debugSessionStarted':
- case 'debugSessionChanged':
- await updateActiveVmServices();
- case 'debugSessionStopped':
- await activeVmServices
- .remove(e.data['debugSessionId'] as String)
- ?.then((service) => service.dispose());
case 'activeLocationChanged':
_activeLocation = e.data;
default:
@@ -593,11 +586,7 @@
}) async {
final dtd = _dtd;
if (dtd == null) return _dtdNotConnected;
- if (!_getDebugSessionsReady) {
- // Give it a chance to get ready.
- await Future<void>.delayed(const Duration(seconds: 1));
- if (!_getDebugSessionsReady) return _dtdNotReady;
- }
+ if (!_connectedAppServiceIsSupported) return _connectedAppsNotSupported;
await updateActiveVmServices();
if (activeVmServices.isEmpty) return _noActiveDebugSession;
@@ -752,6 +741,17 @@
inputSchema: Schema.object(),
);
+ static final _connectedAppsNotSupported = CallToolResult(
+ isError: true,
+ content: [
+ TextContent(
+ text:
+ 'A Dart SDK of version 3.9.0-163.0.dev or greater is required to '
+ 'connect to Dart and Flutter applications.',
+ ),
+ ],
+ );
+
static final _dtdNotConnected = CallToolResult(
isError: true,
content: [
@@ -781,17 +781,6 @@
isError: true,
);
- static final _dtdNotReady = CallToolResult(
- isError: true,
- content: [
- TextContent(
- text:
- 'The dart tooling daemon is not ready yet, please wait a few '
- 'seconds and try again.',
- ),
- ],
- );
-
static final runtimeErrorsScheme = 'runtime-errors';
}
@@ -807,10 +796,10 @@
final StreamController<String> _errorsController;
/// The listener for Flutter.Error vm service extension events.
- final StreamSubscription<Event> _extensionEventsListener;
+ final StreamSubscription<Event>? _extensionEventsListener;
/// The stderr listener on the flutter process.
- final StreamSubscription<Event> _stderrEventsListener;
+ final StreamSubscription<Event>? _stderrEventsListener;
/// The vm service instance connected to the flutter app.
final VmService _vmService;
@@ -846,32 +835,35 @@
// that occurred before this tool call.
// TODO(https://github.com/dart-lang/ai/issues/57): this can result in
// duplicate errors that we need to de-duplicate somehow.
- final extensionEvents = vmService.onExtensionEventWithHistory.listen((
- Event e,
- ) {
- if (e.extensionKind == 'Flutter.Error') {
+ StreamSubscription<Event>? extensionEvents;
+ StreamSubscription<Event>? stderrEvents;
+
+ try {
+ extensionEvents = vmService.onExtensionEventWithHistory.listen((
+ Event e,
+ ) {
+ if (e.extensionKind == 'Flutter.Error') {
+ // TODO(https://github.com/dart-lang/ai/issues/57): consider
+ // pruning this content down to only what is useful for the LLM to
+ // understand the error and its source.
+ errorsController.add(e.json.toString());
+ }
+ });
+ Event? lastError;
+ stderrEvents = vmService.onStderrEventWithHistory.listen((Event e) {
+ if (lastError case final last?
+ when last.timestamp == e.timestamp && last.bytes == e.bytes) {
+ // Looks like a duplicate event, on Dart 3.7 stable we get these.
+ return;
+ }
+ lastError = e;
+ final message = decodeBase64(e.bytes!);
// TODO(https://github.com/dart-lang/ai/issues/57): consider
// pruning this content down to only what is useful for the LLM to
// understand the error and its source.
- errorsController.add(e.json.toString());
- }
- });
- Event? lastError;
- final stderrEvents = vmService.onStderrEventWithHistory.listen((Event e) {
- if (lastError case final last?
- when last.timestamp == e.timestamp && last.bytes == e.bytes) {
- // Looks like a duplicate event, on Dart 3.7 stable we get these.
- return;
- }
- lastError = e;
- final message = decodeBase64(e.bytes!);
- // TODO(https://github.com/dart-lang/ai/issues/57): consider
- // pruning this content down to only what is useful for the LLM to
- // understand the error and its source.
- errorsController.add(message);
- });
+ errorsController.add(message);
+ });
- try {
await [
vmService.streamListen(EventStreams.kExtension),
vmService.streamListen(EventStreams.kStderr),
@@ -892,8 +884,8 @@
Future<void> shutdown() async {
errorLog.clear();
await _errorsController.close();
- await _extensionEventsListener.cancel();
- await _stderrEventsListener.cancel();
+ await _extensionEventsListener?.cancel();
+ await _stderrEventsListener?.cancel();
try {
await _vmService.streamCancel(EventStreams.kExtension);
await _vmService.streamCancel(EventStreams.kStderr);
@@ -903,93 +895,6 @@
}
}
-/// Adds the [getDebugSessions] method to [DartToolingDaemon], so that calling
-/// the Editor.getDebugSessions service method can be wrapped nicely behind a
-/// method call from a given client.
-//
-// TODO: Consider moving some of this to a shared location, possible under the
-// dtd package.
-extension GetDebugSessions on DartToolingDaemon {
- Future<GetDebugSessionsResponse> getDebugSessions() async {
- final result = await call(
- 'Editor',
- 'getDebugSessions',
- params: GetDebugSessionsRequest(),
- );
- return GetDebugSessionsResponse.fromDTDResponse(result);
- }
-}
-
-/// The request type for the `Editor.getDebugSessions` extension method.
-//
-// TODO: Consider moving some of this to a shared location, possible under the
-// dtd package.
-extension type GetDebugSessionsRequest.fromJson(Map<String, Object?> _value)
- implements Map<String, Object?> {
- factory GetDebugSessionsRequest({bool? verbose}) =>
- GetDebugSessionsRequest.fromJson({
- if (verbose != null) 'verbose': verbose,
- });
-
- bool? get verbose => _value['verbose'] as bool?;
-}
-
-/// The response type for the `Editor.getDebugSessions` extension method.
-//
-// TODO: Consider moving some of this to a shared location, possible under the
-// dtd package.
-extension type GetDebugSessionsResponse.fromJson(Map<String, Object?> _value)
- implements Map<String, Object?> {
- static const String type = 'GetDebugSessionsResult';
-
- List<DebugSession> get debugSessions =>
- (_value['debugSessions'] as List).cast<DebugSession>();
-
- factory GetDebugSessionsResponse.fromDTDResponse(DTDResponse response) {
- // Ensure that the response has the type you expect.
- if (response.type != type) {
- throw RpcException.invalidParams(
- 'Expected DTDResponse.type to be $type, got: ${response.type}',
- );
- }
- return GetDebugSessionsResponse.fromJson(response.result);
- }
-
- factory GetDebugSessionsResponse({
- required List<DebugSession> debugSessions,
- }) => GetDebugSessionsResponse.fromJson({
- 'debugSessions': debugSessions,
- 'type': type,
- });
-}
-
-/// An individual debug session.
-//
-// TODO: Consider moving some of this to a shared location, possible under the
-// dtd package.
-extension type DebugSession.fromJson(Map<String, Object?> _value)
- implements Map<String, Object?> {
- String get debuggerType => _value['debuggerType'] as String;
- String get id => _value['id'] as String;
- String get name => _value['name'] as String;
- String get projectRootPath => _value['projectRootPath'] as String;
- String? get vmServiceUri => _value['vmServiceUri'] as String?;
-
- factory DebugSession({
- required String debuggerType,
- required String id,
- required String name,
- required String projectRootPath,
- required String? vmServiceUri,
- }) => DebugSession.fromJson({
- 'debuggerType': debuggerType,
- 'id': id,
- 'name': name,
- 'projectRootPath': projectRootPath,
- if (vmServiceUri != null) 'vmServiceUri': vmServiceUri,
- });
-}
-
/// Manages a log of errors with a maximum size in terms of total characters.
@visibleForTesting
class ErrorLog {
@@ -1038,3 +943,9 @@
_errors.clear();
}
}
+
+extension on VmService {
+ static final _ids = Expando<String>();
+ static int _nextId = 0;
+ String get id => _ids[this] ??= '${_nextId++}';
+}
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart b/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart
index 5b16eb4..fc5c83e 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart
@@ -43,11 +43,9 @@
try {
result = jsonDecode(await _client.read(searchUrl));
- final packageNames =
- dig<List>(result, ['packages'])
- .take(_resultsLimit)
- .map((p) => dig<String>(p, ['package']))
- .toList();
+ final packageNames = dig<List>(result, [
+ 'packages',
+ ]).take(_resultsLimit).map((p) => dig<String>(p, ['package'])).toList();
if (packageNames.isEmpty) {
return CallToolResult(
@@ -71,18 +69,17 @@
}
// Retrieve information about all the packages in parallel.
- final subQueryFutures =
- packageNames
- .map(
- (packageName) => (
- versionListing: retrieve('api/packages/$packageName'),
- score: retrieve('api/packages/$packageName/score'),
- docIndex: retrieve(
- 'documentation/$packageName/latest/index.json',
- ),
- ),
- )
- .toList();
+ final subQueryFutures = packageNames
+ .map(
+ (packageName) => (
+ versionListing: retrieve('api/packages/$packageName'),
+ score: retrieve('api/packages/$packageName/score'),
+ docIndex: retrieve(
+ 'documentation/$packageName/latest/index.json',
+ ),
+ ),
+ )
+ .toList();
// Aggregate the retrieved information about each package into a
// TextContent.
@@ -97,11 +94,10 @@
?.cast<Map<String, Object?>>() ??
<Map<String, Object?>>[])
if (!object.containsKey('enclosedBy'))
- object['name'] as String:
- Uri.https(
- 'pub.dev',
- 'documentation/$packageName/latest/${object['href']}',
- ).toString(),
+ object['name'] as String: Uri.https(
+ 'pub.dev',
+ 'documentation/$packageName/latest/${object['href']}',
+ ).toString(),
};
results.add(
TextContent(
@@ -151,19 +147,15 @@
'downloadCount30Days',
]),
},
- 'topics':
- dig<List>(
- scoreResult,
- ['tags'],
- ).where((t) => (t as String).startsWith('topic:')).toList(),
- 'licenses':
- dig<List>(scoreResult, ['tags'])
- .where((t) => (t as String).startsWith('license'))
- .toList(),
- 'publisher':
- dig<List>(scoreResult, ['tags'])
- .where((t) => (t as String).startsWith('publisher:'))
- .firstOrNull,
+ 'topics': dig<List>(scoreResult, [
+ 'tags',
+ ]).where((t) => (t as String).startsWith('topic:')).toList(),
+ 'licenses': dig<List>(scoreResult, [
+ 'tags',
+ ]).where((t) => (t as String).startsWith('license')).toList(),
+ 'publisher': dig<List>(scoreResult, ['tags'])
+ .where((t) => (t as String).startsWith('publisher:'))
+ .firstOrNull,
},
}),
),
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/roots_fallback_support.dart b/pkgs/dart_mcp_server/lib/src/mixins/roots_fallback_support.dart
index a47f1b9..1c2562a 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/roots_fallback_support.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/roots_fallback_support.dart
@@ -50,8 +50,8 @@
// If the client supports roots, just use their stream (or lack thereof).
// If they don't, use our own stream.
_fallbackEnabled
- ? _rootsListChangedFallbackController?.stream
- : super.rootsListChanged;
+ ? _rootsListChangedFallbackController?.stream
+ : super.rootsListChanged;
StreamController<RootsListChangedNotification>?
_rootsListChangedFallbackController;
@@ -76,8 +76,8 @@
@override
Future<ListRootsResult> listRoots(ListRootsRequest request) async =>
_fallbackEnabled
- ? ListRootsResult(roots: _customRoots.toList())
- : super.listRoots(request);
+ ? ListRootsResult(roots: _customRoots.toList())
+ : super.listRoots(request);
/// Adds the roots in [request] the custom roots and calls [updateRoots].
///
diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart
index 005a68b..e28645f 100644
--- a/pkgs/dart_mcp_server/lib/src/server.dart
+++ b/pkgs/dart_mcp_server/lib/src/server.dart
@@ -86,8 +86,9 @@
parsedArgs.option(flutterSdkOption) ??
io.Platform.environment['FLUTTER_SDK'];
final logFilePath = parsedArgs.option(logFileOption);
- final logFileSink =
- logFilePath == null ? null : _createLogSink(io.File(logFilePath));
+ final logFileSink = logFilePath == null
+ ? null
+ : _createLogSink(io.File(logFilePath));
runZonedGuarded(
() {
server = DartMCPServer(
@@ -177,31 +178,34 @@
analytics == null
? impl
: (CallToolRequest request) async {
- final watch = Stopwatch()..start();
- CallToolResult? result;
- try {
- return result = await impl(request);
- } finally {
- watch.stop();
+ final watch = Stopwatch()..start();
+ CallToolResult? result;
try {
- analytics.send(
- Event.dartMCPEvent(
- client: clientInfo.name,
- clientVersion: clientInfo.version,
- serverVersion: implementation.version,
- type: AnalyticsEvent.callTool.name,
- additionalData: CallToolMetrics(
- tool: request.name,
- success: result != null && result.isError != true,
- elapsedMilliseconds: watch.elapsedMilliseconds,
+ return result = await impl(request);
+ } finally {
+ watch.stop();
+ try {
+ analytics.send(
+ Event.dartMCPEvent(
+ client: clientInfo.name,
+ clientVersion: clientInfo.version,
+ serverVersion: implementation.version,
+ type: AnalyticsEvent.callTool.name,
+ additionalData: CallToolMetrics(
+ tool: request.name,
+ success: result != null && result.isError != true,
+ elapsedMilliseconds: watch.elapsedMilliseconds,
+ ),
),
- ),
- );
- } catch (e) {
- log(LoggingLevel.warning, 'Error sending analytics event: $e');
+ );
+ } catch (e) {
+ log(
+ LoggingLevel.warning,
+ 'Error sending analytics event: $e',
+ );
+ }
}
- }
- },
+ },
validateArguments: validateArguments,
);
}
diff --git a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
index 392fc4e..593627d 100644
--- a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
+++ b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
@@ -85,9 +85,8 @@
List<String> defaultPaths = const <String>[],
required Sdk sdk,
}) async {
- var rootConfigs =
- (request.arguments?[ParameterNames.roots] as List?)
- ?.cast<Map<String, Object?>>();
+ var rootConfigs = (request.arguments?[ParameterNames.roots] as List?)
+ ?.cast<Map<String, Object?>>();
// Default to use the known roots if none were specified.
if (rootConfigs == null || rootConfigs.isEmpty) {
@@ -257,13 +256,12 @@
) async => switch (await inferProjectKind(rootUri, fileSystem)) {
ProjectKind.dart => sdk.dartExecutablePath,
ProjectKind.flutter => sdk.flutterExecutablePath,
- ProjectKind.unknown =>
- throw ArgumentError.value(
- rootUri,
- 'rootUri',
- 'Unknown project kind at root $rootUri. All projects must have a '
- 'pubspec.',
- ),
+ ProjectKind.unknown => throw ArgumentError.value(
+ rootUri,
+ 'rootUri',
+ 'Unknown project kind at root $rootUri. All projects must have a '
+ 'pubspec.',
+ ),
};
/// Returns whether [uri] is under or exactly equal to [root].
diff --git a/pkgs/dart_mcp_server/pubspec.yaml b/pkgs/dart_mcp_server/pubspec.yaml
index a4905ce..f6a32bb 100644
--- a/pkgs/dart_mcp_server/pubspec.yaml
+++ b/pkgs/dart_mcp_server/pubspec.yaml
@@ -4,7 +4,7 @@
models.
publish_to: none
environment:
- sdk: ^3.7.0
+ sdk: ^3.9.0-163.0.dev
executables:
dart_mcp_server: main
@@ -15,11 +15,11 @@
collection: ^1.19.1
dart_mcp: ^0.3.0
dds_service_extensions: ^2.0.1
- devtools_shared: ^11.2.0
- dtd: ^2.4.0
+ devtools_shared: ^12.0.0
+ dtd: ^4.0.0
file: ^7.0.1
http: ^1.3.0
- json_rpc_2: ^3.0.3
+ json_rpc_2: ^4.0.0
# TODO: Get this another way.
language_server_protocol:
git:
diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart
index 178304b..9f95850 100644
--- a/pkgs/dart_mcp_server/test/test_harness.dart
+++ b/pkgs/dart_mcp_server/test/test_harness.dart
@@ -234,8 +234,9 @@
final stdout = StreamQueue(process.stdoutStream());
while (vmServiceUri == null && await stdout.hasNext) {
final line = await stdout.next;
- final serviceString =
- isFlutter ? 'A Dart VM Service' : 'The Dart VM service';
+ final serviceString = isFlutter
+ ? 'A Dart VM Service'
+ : 'The Dart VM service';
if (line.contains(serviceString)) {
vmServiceUri = line
.substring(line.indexOf('http:'))
@@ -268,16 +269,6 @@
await process.shouldExit(anyOf(0, Platform.isWindows ? -1 : -9));
}
}
-
- /// Returns this as the Editor service representation.
- DebugSession asEditorDebugSession({required bool includeVmServiceUri}) =>
- DebugSession(
- debuggerType: isFlutter ? 'Flutter' : 'Dart',
- id: id,
- name: 'Test app',
- projectRootPath: projectRoot,
- vmServiceUri: includeVmServiceUri ? vmServiceUri : null,
- );
}
/// A basic MCP client which is started as a part of the harness.
@@ -302,8 +293,9 @@
final TestProcess dtdProcess;
final DartToolingDaemon dtd;
final String dtdUri;
+ final String dtdSecret;
- FakeEditorExtension._(this.dtd, this.dtdProcess, this.dtdUri);
+ FakeEditorExtension._(this.dtd, this.dtdProcess, this.dtdUri, this.dtdSecret);
static int get nextId => ++_nextId;
static int _nextId = 0;
@@ -311,48 +303,29 @@
static Future<FakeEditorExtension> connect(Sdk sdk) async {
final dtdProcess = await TestProcess.start(sdk.dartExecutablePath, [
'tooling-daemon',
+ '--machine',
]);
- final dtdUri = await _getDTDUri(dtdProcess);
+ final (:dtdUri, :dtdSecret) = await _getDTDInfo(dtdProcess);
final dtd = await DartToolingDaemon.connect(Uri.parse(dtdUri));
- final extension = FakeEditorExtension._(dtd, dtdProcess, dtdUri);
- await extension._registerService();
- return extension;
+ return FakeEditorExtension._(dtd, dtdProcess, dtdUri, dtdSecret);
}
Future<void> addDebugSession(AppDebugSession session) async {
_debugSessions.add(session);
- await dtd.postEvent(
- 'Editor',
- 'debugSessionStarted',
- session.asEditorDebugSession(includeVmServiceUri: false),
- );
- // Fake a delay between session start and session ready (vm service URI is
- // known).
- await Future<void>.delayed(const Duration(milliseconds: 10));
- await dtd.postEvent(
- 'Editor',
- 'debugSessionChanged',
- session.asEditorDebugSession(includeVmServiceUri: true),
+ await dtd.registerVmService(
+ uri: session.vmServiceUri,
+ secret: dtdSecret,
+ name: session.id,
);
}
Future<void> removeDebugSession(AppDebugSession session) async {
if (_debugSessions.remove(session)) {
- await dtd.postEvent('Editor', 'debugSessionStopped', {
- 'debugSessionId': session.id,
- });
- }
- }
-
- Future<void> _registerService() async {
- await dtd.registerService('Editor', 'getDebugSessions', (request) async {
- return GetDebugSessionsResponse(
- debugSessions: [
- for (var debugSession in debugSessions)
- debugSession.asEditorDebugSession(includeVmServiceUri: true),
- ],
+ await dtd.unregisterVmService(
+ uri: session.vmServiceUri,
+ secret: dtdSecret,
);
- });
+ }
}
Future<void> shutdown() async {
@@ -363,29 +336,22 @@
}
/// Reads DTD uri from the [dtdProcess] output.
-Future<String> _getDTDUri(TestProcess dtdProcess) async {
- String? dtdUri;
- final stdout = StreamQueue(dtdProcess.stdoutStream());
- while (await stdout.hasNext) {
- final line = await stdout.next;
- const devtoolsLineStart = 'The Dart Tooling Daemon is listening on';
- if (line.startsWith(devtoolsLineStart)) {
- dtdUri = line.substring(line.indexOf('ws:'));
- await stdout.cancel();
- break;
- }
- }
- if (dtdUri == null) {
- throw StateError(
- 'Failed to scrape the Dart Tooling Daemon URI from the process output.',
- );
- }
-
- return dtdUri;
+Future<({String dtdUri, String dtdSecret})> _getDTDInfo(
+ TestProcess dtdProcess,
+) async {
+ final decoded =
+ jsonDecode(await dtdProcess.stdoutStream().first) as Map<String, Object?>;
+ final details = decoded['tooling_daemon_details'] as Map<String, Object?>;
+ return (
+ dtdUri: details['uri'] as String,
+ dtdSecret: details['trusted_client_secret'] as String,
+ );
}
-typedef ServerConnectionPair =
- ({ServerConnection serverConnection, DartMCPServer? server});
+typedef ServerConnectionPair = ({
+ ServerConnection serverConnection,
+ DartMCPServer? server,
+});
/// Starts up the [DartMCPServer] and connects [client] to it.
///
diff --git a/pkgs/dart_mcp_server/test/tools/dtd_test.dart b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
index 491f013..89f4559 100644
--- a/pkgs/dart_mcp_server/test/tools/dtd_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
@@ -197,6 +197,9 @@
);
await pumpEventQueue();
expect(server.activeVmServices.length, 1);
+ // TODO: It can cause an error in the mcp server if we haven't set
+ // up the listeners yet.
+ await Future<void>.delayed(const Duration(seconds: 1));
await testHarness.stopDebugSession(debugSession);
await pumpEventQueue();
@@ -412,16 +415,14 @@
final stdin = debugSession.appProcess.stdin;
stdin.writeln('');
- var resources =
- (await serverConnection.listResources(
- ListResourcesRequest(),
- )).resources;
+ var resources = (await serverConnection.listResources(
+ ListResourcesRequest(),
+ )).resources;
if (resources.runtimeErrors.isEmpty) {
await onResourceListChanged;
- resources =
- (await serverConnection.listResources(
- ListResourcesRequest(),
- )).resources;
+ resources = (await serverConnection.listResources(
+ ListResourcesRequest(),
+ )).resources;
}
final resource = resources.runtimeErrors.single;
@@ -431,10 +432,9 @@
await serverConnection.subscribeResource(
SubscribeRequest(uri: resource.uri),
);
- var originalContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
+ var originalContents = (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
final errorMatcher = isA<TextResourceContents>().having(
(c) => c.text,
'text',
@@ -444,10 +444,9 @@
// re-read the resource.
if (originalContents.isEmpty) {
await resourceUpdatedQueue.next;
- originalContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
+ originalContents = (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
}
expect(
originalContents.length,
@@ -467,10 +466,9 @@
);
// Should now have another error.
- final newContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
+ final newContents = (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
expect(newContents.length, 2);
expect(newContents.last, errorMatcher);
@@ -482,10 +480,9 @@
),
);
- final finalContents =
- (await serverConnection.readResource(
- ReadResourceRequest(uri: resource.uri),
- )).contents;
+ final finalContents = (await serverConnection.readResource(
+ ReadResourceRequest(uri: resource.uri),
+ )).contents;
expect(finalContents, isEmpty);
expect(
diff --git a/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart b/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart
index 11feb5e..436ac6c 100644
--- a/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart
@@ -213,10 +213,8 @@
_FixedResponseClient(this.handler);
_FixedResponseClient.withMappedResponses(Map<String, String> responses)
- : handler =
- ((url) =>
- responses[url.toString()] ??
- (throw ClientException('No internet')));
+ : handler = ((url) =>
+ responses[url.toString()] ?? (throw ClientException('No internet')));
@override
Future<String> read(Uri url, {Map<String, String>? headers}) async {
diff --git a/pkgs/dart_mcp_server/test/tools/pub_test.dart b/pkgs/dart_mcp_server/test/tools/pub_test.dart
index e349db1..48fa6d1 100644
--- a/pkgs/dart_mcp_server/test/tools/pub_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/pub_test.dart
@@ -28,18 +28,17 @@
final executableName =
'$appKind${Platform.isWindows
? appKind == 'dart'
- ? '.exe'
- : '.bat'
+ ? '.exe'
+ : '.bat'
: ''}';
group('$appKind app', () {
// TODO: Use setUpAll, currently this fails due to an apparent TestProcess
// issue.
setUp(() async {
fileSystem = MemoryFileSystem(
- style:
- Platform.isWindows
- ? FileSystemStyle.windows
- : FileSystemStyle.posix,
+ style: Platform.isWindows
+ ? FileSystemStyle.windows
+ : FileSystemStyle.posix,
);
fileSystem.file(p.join(fakeAppPath, 'pubspec.yaml'))
..createSync(recursive: true)