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)