From f6c4e5e0ba8b0afa35db8613664d6c555d1a7f75 Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Mon, 21 Apr 2025 17:47:25 +0000 Subject: [PATCH 1/6] checkpoint, spawning server ourselves --- .../lib/src/lsp/wire_format.dart | 105 ++++++ .../lib/src/mixins/analyzer.dart | 350 ++++++++---------- pkgs/dart_tooling_mcp_server/pubspec.yaml | 11 +- .../test/tools/analyzer_test.dart | 142 +++---- 4 files changed, 334 insertions(+), 274 deletions(-) create mode 100644 pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart diff --git a/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart b/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart new file mode 100644 index 0000000..ba9bac3 --- /dev/null +++ b/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart @@ -0,0 +1,105 @@ +// 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 'dart:convert'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +StreamChannel lspChannel( + Stream> stream, + StreamSink> sink, +) { + final parser = _Parser(stream); + final outSink = StreamSinkTransformer.fromHandlers( + handleData: _serialize, + handleDone: (sink) { + sink.close(); + parser.close(); + }, + ).bind(sink); + return StreamChannel.withGuarantees(parser.stream, outSink); +} + +void _serialize(String data, EventSink> sink) { + final message = utf8.encode(data); + final header = 'Content-Length: ${message.length}\r\n\r\n'; + sink.add(ascii.encode(header)); + for (var chunk in _chunks(message, 1024)) { + sink.add(chunk); + } +} + +class _Parser { + final _streamCtl = StreamController(); + Stream get stream => _streamCtl.stream; + + final _buffer = []; + bool _headerMode = true; + int _contentLength = -1; + + late final StreamSubscription _subscription; + + _Parser(Stream> stream) { + _subscription = stream + .expand((bytes) => bytes) + .listen(_handleByte, onDone: _streamCtl.close); + } + + Future close() => _subscription.cancel(); + + void _handleByte(int byte) { + _buffer.add(byte); + if (_headerMode && _headerComplete) { + _contentLength = _parseContentLength(); + _buffer.clear(); + _headerMode = false; + } else if (!_headerMode && _messageComplete) { + _streamCtl.add(utf8.decode(_buffer)); + _buffer.clear(); + _headerMode = true; + } + } + + /// Whether the entire message is in [_buffer]. + bool get _messageComplete => _buffer.length >= _contentLength; + + /// Decodes [_buffer] into a String and looks for the 'Content-Length' header. + int _parseContentLength() { + final asString = ascii.decode(_buffer); + final headers = asString.split('\r\n'); + final lengthHeader = headers.firstWhere( + (h) => h.startsWith('Content-Length'), + ); + final length = lengthHeader.split(':').last.trim(); + return int.parse(length); + } + + /// Whether [_buffer] ends in '\r\n\r\n'. + bool get _headerComplete { + final l = _buffer.length; + return l > 4 && + _buffer[l - 1] == 10 && + _buffer[l - 2] == 13 && + _buffer[l - 3] == 10 && + _buffer[l - 4] == 13; + } +} + +Iterable> _chunks(List data, int chunkSize) sync* { + if (data.length <= chunkSize) { + yield data; + return; + } + var low = 0; + while (low < data.length) { + if (data.length > low + chunkSize) { + yield data.sublist(low, low + chunkSize); + } else { + yield data.sublist(low); + } + low += chunkSize; + } +} diff --git a/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart b/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart index 5be2de3..e4c7583 100644 --- a/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart +++ b/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart @@ -3,35 +3,43 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; import 'dart:io'; -import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; -import 'package:analyzer/dart/analysis/results.dart'; import 'package:dart_mcp/server.dart'; -import 'package:meta/meta.dart'; -import 'package:path/path.dart' as p; -import 'package:watcher/watcher.dart'; +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:language_server_protocol/protocol_custom_generated.dart' as lsp; +import 'package:language_server_protocol/protocol_generated.dart' as lsp; +import 'package:language_server_protocol/protocol_special.dart' as lsp; + +import '../lsp/wire_format.dart'; /// Mix this in to any MCPServer to add support for analyzing Dart projects. /// /// The MCPServer must already have the [ToolsSupport] and [LoggingSupport] /// mixins applied. base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { - /// The analyzed contexts. - AnalysisContextCollection? _analysisContexts; + /// The LSP server connection for the analysis server. + late final Peer _lspConnection; - /// All active directory watcher streams. - /// - /// The watcher package doesn't let you close watchers, you just have to - /// stop listening to their streams instead, so we store the stream - /// subscriptions instead of the watchers themselves. - final List _watchSubscriptions = []; + /// The actual process for the LSP server. + late final Process _lspServer; + + /// The current diagnostics for a given file. + Map> diagnostics = {}; + + /// All known workspace roots. + Set workspaceRoots = HashSet( + equals: (r1, r2) => r2.uri == r2.uri, + hashCode: (r) => r.uri.hashCode, + ); @override - FutureOr initialize(InitializeRequest request) { + FutureOr initialize(InitializeRequest request) async { // We check for requirements and store a message to log after initialization // if some requirement isn't satisfied. - final unsupportedReason = + var unsupportedReason = request.capabilities.roots == null ? 'Project analysis requires the "roots" capability which is not ' 'supported. Analysis tools have been disabled.' @@ -41,214 +49,154 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { 'dart SDK). Analysis tools have been disabled.' : null); - if (unsupportedReason == null) { - // Requirements met, register the tool. - registerTool(analyzeFilesTool, _analyzeFiles); - } + unsupportedReason ??= await _initializeAnalyzerLspServer(); // Don't call any methods on the client until we are fully initialized // (even logging). - initialized.then((_) { - if (unsupportedReason != null) { - log(LoggingLevel.warning, unsupportedReason); - } else { - // All requirements satisfied, ask the client for its roots. - _listenForRoots(); - } - }); + unawaited( + initialized.then((_) { + if (unsupportedReason != null) { + log(LoggingLevel.warning, unsupportedReason); + } else { + // All requirements satisfied, ask the client for its roots. + _listenForRoots(); + } + }), + ); return super.initialize(request); } + /// Initializes the analyzer lsp server. + /// + /// On success, returns `null`. + /// + /// On failure, returns a reason for the failure. + Future _initializeAnalyzerLspServer() async { + _lspServer = await Process.start('dart', [ + 'language-server', + '--protocol', + 'lsp', + '--protocol-traffic-log', + 'language-server-protocol.log', + ]); + _lspServer.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + stderr.writeln('[StdErr from analyzer lsp server]: $line'); + }); + final channel = lspChannel(_lspServer.stdout, _lspServer.stdin); + _lspConnection = Peer(channel); + + _lspConnection.registerMethod( + lsp.Method.textDocument_publishDiagnostics.toString(), + _handleDiagnostics, + ); + + stderr.writeln('initializing lsp server'); + lsp.InitializeResult? initializeResult; + try { + /// Initialize with the server. + lsp.InitializeResult.fromJson( + (await _lspConnection.sendRequest( + lsp.Method.initialize.toString(), + lsp.InitializeParams( + capabilities: lsp.ClientCapabilities( + workspace: lsp.WorkspaceClientCapabilities( + diagnostics: lsp.DiagnosticWorkspaceClientCapabilities( + refreshSupport: true, + ), + ), + textDocument: lsp.TextDocumentClientCapabilities( + publishDiagnostics: + lsp.PublishDiagnosticsClientCapabilities(), + ), + ), + ).toJson(), + )) + as Map, + ); + } catch (e, s) { + stderr.writeln('error initializing lsp server'); + stderr.writeln(e); + stderr.writeln(s); + return 'error initializing lsp server'; + } + stderr.writeln('done initializing lsp server'); + + String? error; + final workspaceSupport = + initializeResult!.capabilities.workspace?.workspaceFolders; + if (workspaceSupport?.supported != true) { + error = 'Workspaces are not supported by the LSP server'; + } + if (workspaceSupport?.changeNotifications?.valueEquals(true) != true) { + error = + 'Workspace change notifications are not supported by the LSP ' + 'server'; + } + + if (error != null) { + _lspServer.kill(); + await _lspConnection.close(); + } else { + _lspConnection.sendNotification(lsp.Method.initialized.toString()); + } + + return error; + } + @override Future shutdown() async { await super.shutdown(); - await _analysisContexts?.dispose(); - _disposeWatchSubscriptions(); + _lspServer.kill(); + await _lspConnection.close(); } - /// Cancels all [_watchSubscriptions] and clears the list. - void _disposeWatchSubscriptions() async { - for (var subscription in _watchSubscriptions) { - unawaited(subscription.cancel()); - } - _watchSubscriptions.clear(); + void _handleDiagnostics(Parameters params) { + final diagnosticParams = lsp.PublishDiagnosticsParams.fromJson( + params.value as Map, + ); + diagnostics[diagnosticParams.uri] = diagnosticParams.diagnostics; + log(LoggingLevel.error, { + 'uri': diagnosticParams.uri, + 'diagnostics': + diagnosticParams.diagnostics.map((d) => d.toJson()).toList(), + }, logger: 'Static errors from a root!'); } /// Lists the roots, and listens for changes to them. /// - /// Whenever new roots are found, creates a new [AnalysisContextCollection]. + /// Sends workspace change notifications to the LSP server based on the roots. void _listenForRoots() async { rootsListChanged!.listen((event) async { - unawaited(_analysisContexts?.dispose()); - _analysisContexts = null; - _createAnalysisContext(await listRoots(ListRootsRequest())); + await _updateRoots(); }); - _createAnalysisContext(await listRoots(ListRootsRequest())); + await _updateRoots(); } - /// Creates an analysis context from a list of roots. - // - // TODO: Better configuration for the DART_SDK location. - void _createAnalysisContext(ListRootsResult result) async { - final sdkPath = Platform.environment['DART_SDK']; - if (sdkPath == null) { - throw StateError('DART_SDK environment variable not set'); - } - - final paths = []; - for (var root in result.roots) { - final uri = Uri.parse(root.uri); - if (uri.scheme != 'file') { - throw ArgumentError.value( - root.uri, - 'uri', - 'Only file scheme uris are allowed for roots', - ); - } - paths.add(p.normalize(uri.path)); - } - - _disposeWatchSubscriptions(); - - for (var rootPath in paths) { - final watcher = DirectoryWatcher(rootPath); - _watchSubscriptions.add( - watcher.events.listen( - (event) { - try { - _analysisContexts - ?.contextFor(event.path) - .changeFile(p.normalize(event.path)); - } catch (_) { - // Fail gracefully. - // TODO(https://github.com/dart-lang/ai/issues/65): remove this - // catch if possible. - } - }, - onError: (Object error, StackTrace stackTrace) { - // We can get spurious file system errors, likely based on race - // conditions. We can safely just ignore those. - if (error is FileSystemException) return; - // Re-throw all other errors. - throw error; // ignore: only_throw_errors - }, - ), - ); - } - - _analysisContexts = AnalysisContextCollection( - includedPaths: paths, - sdkPath: sdkPath, + /// Updates the set of [workspaceRoots] and notifies the server. + Future _updateRoots() async { + final newRoots = HashSet( + equals: (r1, r2) => r1.uri == r2.uri, + hashCode: (r) => r.uri.hashCode, + )..addAll((await listRoots(ListRootsRequest())).roots); + final removed = workspaceRoots.difference(newRoots); + final added = newRoots.difference(workspaceRoots); + + workspaceRoots = newRoots; + _lspConnection.sendNotification( + lsp.Method.workspace_didChangeWorkspaceFolders.toString(), + lsp.WorkspaceFoldersChangeEvent( + added: [for (var root in added) root.asWorkspaceFolder], + removed: [for (var root in removed) root.asWorkspaceFolder], + ), ); } +} - /// Implementation of the [analyzeFilesTool], analyzes the requested files - /// under the requested project roots. - Future _analyzeFiles(CallToolRequest request) async { - final contexts = _analysisContexts; - if (contexts == null) { - return CallToolResult( - content: [ - TextContent( - text: - 'Analysis not yet ready, please wait a few seconds and try ' - 'again.', - ), - ], - isError: true, - ); - } - - final messages = []; - final rootConfigs = - (request.arguments!['roots'] as List).cast>(); - for (var rootConfig in rootConfigs) { - final rootUri = Uri.parse(rootConfig['root'] as String); - if (rootUri.scheme != 'file') { - return CallToolResult( - content: [ - TextContent( - text: - 'Only file scheme uris are allowed for roots, but got ' - '$rootUri', - ), - ], - isError: true, - ); - } - final paths = (rootConfig['paths'] as List?)?.cast(); - if (paths == null) { - return CallToolResult( - content: [ - TextContent( - text: - 'Missing required argument `paths`, which should be the list ' - 'of relative paths to analyze.', - ), - ], - isError: true, - ); - } - - final context = contexts.contextFor(p.normalize(rootUri.path)); - await context.applyPendingFileChanges(); - - for (var path in paths) { - final normalized = p.normalize( - p.isAbsolute(path) ? path : p.join(rootUri.path, path), - ); - final errorsResult = await context.currentSession.getErrors(normalized); - if (errorsResult is! ErrorsResult) { - return CallToolResult( - content: [ - TextContent( - text: 'Error computing analyzer errors $errorsResult', - ), - ], - ); - } - for (var error in errorsResult.errors) { - messages.add(TextContent(text: 'Error: ${error.message}')); - if (error.correctionMessage case final correctionMessage?) { - messages.add(TextContent(text: correctionMessage)); - } - } - } - } - - return CallToolResult(content: messages); - } - - @visibleForTesting - static final analyzeFilesTool = Tool( - name: 'analyze_files', - description: - 'Analyzes the requested file paths under the specified project roots ' - 'and returns the results as a list of messages.', - inputSchema: ObjectSchema( - properties: { - 'roots': ListSchema( - title: 'All projects roots to analyze', - description: - 'These must match a root returned by a call to "listRoots".', - items: ObjectSchema( - properties: { - 'root': StringSchema( - title: 'The URI of the project root to analyze.', - ), - 'paths': ListSchema( - title: - 'Relative or absolute paths to analyze under the ' - '"root", must correspond to files and not directories.', - items: StringSchema(), - ), - }, - required: ['root'], - ), - ), - }, - required: ['roots'], - ), - ); +extension on Root { + lsp.WorkspaceFolder get asWorkspaceFolder => + lsp.WorkspaceFolder(name: name ?? '', uri: Uri.parse(uri)); } diff --git a/pkgs/dart_tooling_mcp_server/pubspec.yaml b/pkgs/dart_tooling_mcp_server/pubspec.yaml index bb0c450..47593d8 100644 --- a/pkgs/dart_tooling_mcp_server/pubspec.yaml +++ b/pkgs/dart_tooling_mcp_server/pubspec.yaml @@ -11,14 +11,19 @@ dependencies: dds_service_extensions: ^2.0.1 devtools_shared: ^11.2.0 dtd: ^2.4.0 - json_rpc_2: ^3.0.3 + json_rpc_2: ^4.0.0 + # TODO: Get this another way. + language_server_protocol: + git: + url: https://github.com/dart-lang/sdk.git + path: + third_party/pkg/language_server_protocol meta: ^1.16.0 path: ^1.9.1 stream_channel: ^2.1.4 test_descriptor: ^2.0.2 test_process: ^2.1.1 vm_service: ^15.0.0 - watcher: ^1.1.1 executables: dart_tooling_mcp_server: main dev_dependencies: @@ -28,3 +33,5 @@ dev_dependencies: dependency_overrides: dart_mcp: path: ../dart_mcp + json_rpc_2: + path: ../../tools/pkgs/json_rpc_2 diff --git a/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart b/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart index 17aa193..a939b0d 100644 --- a/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart +++ b/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart @@ -18,83 +18,83 @@ void main() { testHarness = await TestHarness.start(); }); - group('analyzer tools', () { - late Tool analyzeTool; + // group('analyzer tools', () { + // late Tool analyzeTool; - setUp(() async { - final tools = (await testHarness.mcpServerConnection.listTools()).tools; - analyzeTool = tools.singleWhere( - (t) => t.name == DartAnalyzerSupport.analyzeFilesTool.name, - ); - }); + // setUp(() async { + // final tools = (await testHarness.mcpServerConnection.listTools()).tools; + // analyzeTool = tools.singleWhere( + // (t) => t.name == DartAnalyzerSupport.analyzeFilesTool.name, + // ); + // }); - test('can analyze a project', () async { - final counterAppRoot = rootForPath(counterAppPath); - testHarness.mcpClient.addRoot(counterAppRoot); - // Allow the notification to propagate, and the server to ask for the new - // list of roots. - await pumpEventQueue(); + // test('can analyze a project', () async { + // final counterAppRoot = rootForPath(counterAppPath); + // testHarness.mcpClient.addRoot(counterAppRoot); + // // Allow the notification to propagate, and the server to ask for the new + // // list of roots. + // await pumpEventQueue(); - final request = CallToolRequest( - name: analyzeTool.name, - arguments: { - 'roots': [ - { - 'root': counterAppRoot.uri, - 'paths': ['lib/main.dart'], - }, - ], - }, - ); - final result = await testHarness.callToolWithRetry(request); - expect(result.isError, isNot(true)); - expect(result.content, isEmpty); - }); + // final request = CallToolRequest( + // name: analyzeTool.name, + // arguments: { + // 'roots': [ + // { + // 'root': counterAppRoot.uri, + // 'paths': ['lib/main.dart'], + // }, + // ], + // }, + // ); + // final result = await testHarness.callToolWithRetry(request); + // expect(result.isError, isNot(true)); + // expect(result.content, isEmpty); + // }); - test('can handle project changes', () async { - final example = d.dir('example', [ - d.file('main.dart', 'void main() => 1 + "2";'), - ]); - await example.create(); - final exampleRoot = rootForPath(example.io.path); - testHarness.mcpClient.addRoot(exampleRoot); + // test('can handle project changes', () async { + // final example = d.dir('example', [ + // d.file('main.dart', 'void main() => 1 + "2";'), + // ]); + // await example.create(); + // final exampleRoot = rootForPath(example.io.path); + // testHarness.mcpClient.addRoot(exampleRoot); - // Allow the notification to propagate, and the server to ask for the new - // list of roots. - await pumpEventQueue(); + // // Allow the notification to propagate, and the server to ask for the new + // // list of roots. + // await pumpEventQueue(); - final request = CallToolRequest( - name: analyzeTool.name, - arguments: { - 'roots': [ - { - 'root': exampleRoot.uri, - 'paths': ['main.dart'], - }, - ], - }, - ); - var result = await testHarness.callToolWithRetry(request); - expect(result.isError, isNot(true)); - expect(result.content, [ - TextContent( - text: - "Error: The argument type 'String' can't be assigned to the " - "parameter type 'num'. ", - ), - ]); + // final request = CallToolRequest( + // name: analyzeTool.name, + // arguments: { + // 'roots': [ + // { + // 'root': exampleRoot.uri, + // 'paths': ['main.dart'], + // }, + // ], + // }, + // ); + // var result = await testHarness.callToolWithRetry(request); + // expect(result.isError, isNot(true)); + // expect(result.content, [ + // TextContent( + // text: + // "Error: The argument type 'String' can't be assigned to the " + // "parameter type 'num'. ", + // ), + // ]); - // Change the file to fix the error - await d.dir('example', [ - d.file('main.dart', 'void main() => 1 + 2;'), - ]).create(); - // Wait for the file watcher to pick up the change, the default delay for - // a polling watcher is one second. - await Future.delayed(const Duration(seconds: 1)); + // // Change the file to fix the error + // await d.dir('example', [ + // d.file('main.dart', 'void main() => 1 + 2;'), + // ]).create(); + // // Wait for the file watcher to pick up the change, the default delay for + // // a polling watcher is one second. + // await Future.delayed(const Duration(seconds: 1)); - result = await testHarness.callToolWithRetry(request); - expect(result.isError, isNot(true)); - expect(result.content, isEmpty); - }); - }); + // result = await testHarness.callToolWithRetry(request); + // expect(result.isError, isNot(true)); + // expect(result.content, isEmpty); + // }); + // }); } From e19720135d45e81d5f5d8346bc7017cae720918a Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Mon, 21 Apr 2025 22:36:18 +0000 Subject: [PATCH 2/6] get analysis working via LSP --- pkgs/dart_mcp/example/dash_client.dart | 2 +- .../lib/src/lsp/wire_format.dart | 29 +++- .../lib/src/mixins/analyzer.dart | 138 +++++++++++++----- pkgs/dart_tooling_mcp_server/pubspec.yaml | 2 - .../test/tools/analyzer_test.dart | 131 ++++++++--------- 5 files changed, 191 insertions(+), 111 deletions(-) diff --git a/pkgs/dart_mcp/example/dash_client.dart b/pkgs/dart_mcp/example/dash_client.dart index dc91899..e71b89d 100644 --- a/pkgs/dart_mcp/example/dash_client.dart +++ b/pkgs/dart_mcp/example/dash_client.dart @@ -39,7 +39,7 @@ final argParser = abbr: 's', help: 'A command to run to start an MCP server', ) - ..addOption( + ..addFlag( 'verbose', abbr: 'v', help: 'Enables verbose logging for logs from servers.', diff --git a/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart b/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart index ba9bac3..d5e3e8f 100644 --- a/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart +++ b/pkgs/dart_tooling_mcp_server/lib/src/lsp/wire_format.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:async/async.dart'; import 'package:stream_channel/stream_channel.dart'; +/// Handles LSP communication with its associated headers. StreamChannel lspChannel( Stream> stream, StreamSink> sink, @@ -23,6 +24,9 @@ StreamChannel lspChannel( return StreamChannel.withGuarantees(parser.stream, outSink); } +/// Writes [data] to [sink], with the appropriate content length header. +/// +/// Writes the [data] in 1KB chunks. void _serialize(String data, EventSink> sink) { final message = utf8.encode(data); final header = 'Content-Length: ${message.length}\r\n\r\n'; @@ -32,24 +36,40 @@ void _serialize(String data, EventSink> sink) { } } +/// Parses content headers and the following messages. +/// +/// Returns a [stream] of just the message contents. class _Parser { - final _streamCtl = StreamController(); - Stream get stream => _streamCtl.stream; + /// Controller that gets a single [String] per entire + /// JSON message received as input. + final _messageController = StreamController(); + /// Stream of full JSON messages in [String] form. + Stream get stream => _messageController.stream; + + /// All the input bytes for the message or header we are currently working + /// with. final _buffer = []; + + /// Whether or not we are still parsing the header. bool _headerMode = true; + + /// The parsed content length, or -1. int _contentLength = -1; + /// The subscription for the input bytes stream. late final StreamSubscription _subscription; _Parser(Stream> stream) { _subscription = stream .expand((bytes) => bytes) - .listen(_handleByte, onDone: _streamCtl.close); + .listen(_handleByte, onDone: _messageController.close); } + /// Shut down this parser. Future close() => _subscription.cancel(); + /// Handles each incoming byte one at a time. void _handleByte(int byte) { _buffer.add(byte); if (_headerMode && _headerComplete) { @@ -57,7 +77,7 @@ class _Parser { _buffer.clear(); _headerMode = false; } else if (!_headerMode && _messageComplete) { - _streamCtl.add(utf8.decode(_buffer)); + _messageController.add(utf8.decode(_buffer)); _buffer.clear(); _headerMode = true; } @@ -88,6 +108,7 @@ class _Parser { } } +/// Splits [data] into chunks of at most [chunkSize]. Iterable> _chunks(List data, int chunkSize) sync* { if (data.length <= chunkSize) { yield data; diff --git a/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart b/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart index e4c7583..03be8a9 100644 --- a/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart +++ b/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart @@ -9,9 +9,8 @@ import 'dart:io'; import 'package:dart_mcp/server.dart'; import 'package:json_rpc_2/json_rpc_2.dart'; -import 'package:language_server_protocol/protocol_custom_generated.dart' as lsp; import 'package:language_server_protocol/protocol_generated.dart' as lsp; -import 'package:language_server_protocol/protocol_special.dart' as lsp; +import 'package:meta/meta.dart'; import '../lsp/wire_format.dart'; @@ -29,7 +28,13 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { /// The current diagnostics for a given file. Map> diagnostics = {}; + /// If currently analyzing, a completer which will be completed once analysis + /// is over. + Completer? _doneAnalyzing = Completer(); + /// All known workspace roots. + /// + /// Identity is controlled by the [Root.uri]. Set workspaceRoots = HashSet( equals: (r1, r2) => r2.uri == r2.uri, hashCode: (r) => r.uri.hashCode, @@ -50,6 +55,9 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { : null); unsupportedReason ??= await _initializeAnalyzerLspServer(); + if (unsupportedReason == null) { + registerTool(analyzeFilesTool, _analyzeFiles); + } // Don't call any methods on the client until we are fully initialized // (even logging). @@ -75,16 +83,20 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { Future _initializeAnalyzerLspServer() async { _lspServer = await Process.start('dart', [ 'language-server', + // Required even though it is documented as the default. + // https://github.com/dart-lang/sdk/issues/60574 '--protocol', 'lsp', - '--protocol-traffic-log', - 'language-server-protocol.log', + // Uncomment these to log the analyzer traffic. + // '--protocol-traffic-log', + // 'language-server-protocol.log', ]); _lspServer.stderr .transform(utf8.decoder) .transform(const LineSplitter()) - .listen((line) { - stderr.writeln('[StdErr from analyzer lsp server]: $line'); + .listen((line) async { + await initialized; + log(LoggingLevel.warning, line, logger: 'DartLanguageServer'); }); final channel = lspChannel(_lspServer.stdout, _lspServer.stdin); _lspConnection = Peer(channel); @@ -94,11 +106,20 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { _handleDiagnostics, ); - stderr.writeln('initializing lsp server'); + _lspConnection.registerMethod(r'$/analyzerStatus', _handleAnalyzerStatus); + + _lspConnection.registerFallback((Parameters params) { + log(LoggingLevel.debug, 'fallback: ${params.method} - ${params.asMap}'); + }); + + unawaited(_lspConnection.listen()); + + log(LoggingLevel.debug, 'Connecting to analyzer lsp server'); lsp.InitializeResult? initializeResult; + String? error; try { /// Initialize with the server. - lsp.InitializeResult.fromJson( + initializeResult = lsp.InitializeResult.fromJson( (await _lspConnection.sendRequest( lsp.Method.initialize.toString(), lsp.InitializeParams( @@ -117,33 +138,32 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { )) as Map, ); - } catch (e, s) { - stderr.writeln('error initializing lsp server'); - stderr.writeln(e); - stderr.writeln(s); - return 'error initializing lsp server'; + } catch (e) { + error = 'Error connecting to analyzer lsp server: $e'; } - stderr.writeln('done initializing lsp server'); - String? error; - final workspaceSupport = - initializeResult!.capabilities.workspace?.workspaceFolders; - if (workspaceSupport?.supported != true) { - error = 'Workspaces are not supported by the LSP server'; - } - if (workspaceSupport?.changeNotifications?.valueEquals(true) != true) { - error = - 'Workspace change notifications are not supported by the LSP ' - 'server'; + if (initializeResult != null) { + final workspaceSupport = + initializeResult.capabilities.workspace?.workspaceFolders; + if (workspaceSupport?.supported != true) { + error ??= 'Workspaces are not supported by the LSP server'; + } + if (workspaceSupport?.changeNotifications?.valueEquals(true) != true) { + error ??= + 'Workspace change notifications are not supported by the LSP ' + 'server'; + } } if (error != null) { _lspServer.kill(); await _lspConnection.close(); } else { - _lspConnection.sendNotification(lsp.Method.initialized.toString()); + _lspConnection.sendNotification( + lsp.Method.initialized.toString(), + lsp.InitializedParams().toJson(), + ); } - return error; } @@ -154,16 +174,52 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { await _lspConnection.close(); } + /// Implementation of the [analyzeFilesTool], analyzes all the files in all + /// workspace dirs. + /// + /// Waits for any pending analysis before returning. + Future _analyzeFiles(CallToolRequest request) async { + await _doneAnalyzing?.future; + final messages = []; + for (var entry in diagnostics.entries) { + for (var diagnostic in entry.value) { + final diagnosticJson = diagnostic.toJson(); + diagnosticJson['uri'] = entry.key.toString(); + messages.add(TextContent(text: jsonEncode(diagnosticJson))); + } + } + if (messages.isEmpty) { + messages.add(TextContent(text: 'No errors')); + } + return CallToolResult(content: messages); + } + + /// Handles `$/analyzerStatus` events, which tell us when analysis starts and + /// stops. + void _handleAnalyzerStatus(Parameters params) { + final isAnalyzing = params.asMap['isAnalyzing'] as bool; + if (isAnalyzing) { + /// Leave existing completer in place - we start with one so we don't + /// respond too early to the first analyze request. + _doneAnalyzing ??= Completer(); + } else { + assert(_doneAnalyzing != null); + _doneAnalyzing?.complete(); + _doneAnalyzing = null; + } + } + + /// Handles `textDocument/publishDiagnostics` events. void _handleDiagnostics(Parameters params) { final diagnosticParams = lsp.PublishDiagnosticsParams.fromJson( params.value as Map, ); diagnostics[diagnosticParams.uri] = diagnosticParams.diagnostics; - log(LoggingLevel.error, { + log(LoggingLevel.debug, { 'uri': diagnosticParams.uri, 'diagnostics': diagnosticParams.diagnostics.map((d) => d.toJson()).toList(), - }, logger: 'Static errors from a root!'); + }); } /// Lists the roots, and listens for changes to them. @@ -182,21 +238,37 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { equals: (r1, r2) => r1.uri == r2.uri, hashCode: (r) => r.uri.hashCode, )..addAll((await listRoots(ListRootsRequest())).roots); + final removed = workspaceRoots.difference(newRoots); final added = newRoots.difference(workspaceRoots); - workspaceRoots = newRoots; + + final event = lsp.WorkspaceFoldersChangeEvent( + added: [for (var root in added) root.asWorkspaceFolder], + removed: [for (var root in removed) root.asWorkspaceFolder], + ); + + log( + LoggingLevel.debug, + () => 'Notifying of workspace root change: ${event.toJson()}', + ); + _lspConnection.sendNotification( lsp.Method.workspace_didChangeWorkspaceFolders.toString(), - lsp.WorkspaceFoldersChangeEvent( - added: [for (var root in added) root.asWorkspaceFolder], - removed: [for (var root in removed) root.asWorkspaceFolder], - ), + lsp.DidChangeWorkspaceFoldersParams(event: event).toJson(), ); } + + @visibleForTesting + static final analyzeFilesTool = Tool( + name: 'analyze_files', + description: 'Analyzes the entire project for errors.', + inputSchema: ObjectSchema(), + ); } extension on Root { + /// Converts a [Root] to an [lsp.WorkspaceFolder]. lsp.WorkspaceFolder get asWorkspaceFolder => lsp.WorkspaceFolder(name: name ?? '', uri: Uri.parse(uri)); } diff --git a/pkgs/dart_tooling_mcp_server/pubspec.yaml b/pkgs/dart_tooling_mcp_server/pubspec.yaml index 47593d8..6742cec 100644 --- a/pkgs/dart_tooling_mcp_server/pubspec.yaml +++ b/pkgs/dart_tooling_mcp_server/pubspec.yaml @@ -33,5 +33,3 @@ dev_dependencies: dependency_overrides: dart_mcp: path: ../dart_mcp - json_rpc_2: - path: ../../tools/pkgs/json_rpc_2 diff --git a/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart b/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart index a939b0d..f92b66d 100644 --- a/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart +++ b/pkgs/dart_tooling_mcp_server/test/tools/analyzer_test.dart @@ -18,83 +18,72 @@ void main() { testHarness = await TestHarness.start(); }); - // group('analyzer tools', () { - // late Tool analyzeTool; + group('analyzer tools', () { + late Tool analyzeTool; - // setUp(() async { - // final tools = (await testHarness.mcpServerConnection.listTools()).tools; - // analyzeTool = tools.singleWhere( - // (t) => t.name == DartAnalyzerSupport.analyzeFilesTool.name, - // ); - // }); + setUp(() async { + final tools = (await testHarness.mcpServerConnection.listTools()).tools; + analyzeTool = tools.singleWhere( + (t) => t.name == DartAnalyzerSupport.analyzeFilesTool.name, + ); + }); - // test('can analyze a project', () async { - // final counterAppRoot = rootForPath(counterAppPath); - // testHarness.mcpClient.addRoot(counterAppRoot); - // // Allow the notification to propagate, and the server to ask for the new - // // list of roots. - // await pumpEventQueue(); + test('can analyze a project', () async { + final counterAppRoot = rootForPath(counterAppPath); + testHarness.mcpClient.addRoot(counterAppRoot); + // Allow the notification to propagate, and the server to ask for the new + // list of roots. + await pumpEventQueue(); - // final request = CallToolRequest( - // name: analyzeTool.name, - // arguments: { - // 'roots': [ - // { - // 'root': counterAppRoot.uri, - // 'paths': ['lib/main.dart'], - // }, - // ], - // }, - // ); - // final result = await testHarness.callToolWithRetry(request); - // expect(result.isError, isNot(true)); - // expect(result.content, isEmpty); - // }); + final request = CallToolRequest(name: analyzeTool.name); + final result = await testHarness.callToolWithRetry(request); + expect(result.isError, isNot(true)); + expect( + result.content.single, + isA().having((t) => t.text, 'text', 'No errors'), + ); + }); - // test('can handle project changes', () async { - // final example = d.dir('example', [ - // d.file('main.dart', 'void main() => 1 + "2";'), - // ]); - // await example.create(); - // final exampleRoot = rootForPath(example.io.path); - // testHarness.mcpClient.addRoot(exampleRoot); + test('can handle project changes', () async { + final example = d.dir('example', [ + d.file('main.dart', 'void main() => 1 + "2";'), + ]); + await example.create(); + final exampleRoot = rootForPath(example.io.path); + testHarness.mcpClient.addRoot(exampleRoot); - // // Allow the notification to propagate, and the server to ask for the new - // // list of roots. - // await pumpEventQueue(); + // Allow the notification to propagate, and the server to ask for the new + // list of roots. + await pumpEventQueue(); - // final request = CallToolRequest( - // name: analyzeTool.name, - // arguments: { - // 'roots': [ - // { - // 'root': exampleRoot.uri, - // 'paths': ['main.dart'], - // }, - // ], - // }, - // ); - // var result = await testHarness.callToolWithRetry(request); - // expect(result.isError, isNot(true)); - // expect(result.content, [ - // TextContent( - // text: - // "Error: The argument type 'String' can't be assigned to the " - // "parameter type 'num'. ", - // ), - // ]); + final request = CallToolRequest(name: analyzeTool.name); + var result = await testHarness.callToolWithRetry(request); + expect(result.isError, isNot(true)); + expect(result.content, [ + isA().having( + (t) => t.text, + 'text', + contains( + "The argument type 'String' can't be assigned to the parameter " + "type 'num'.", + ), + ), + ]); - // // Change the file to fix the error - // await d.dir('example', [ - // d.file('main.dart', 'void main() => 1 + 2;'), - // ]).create(); - // // Wait for the file watcher to pick up the change, the default delay for - // // a polling watcher is one second. - // await Future.delayed(const Duration(seconds: 1)); + // Change the file to fix the error + await d.dir('example', [ + d.file('main.dart', 'void main() => 1 + 2;'), + ]).create(); + // Wait for the file watcher to pick up the change, the default delay for + // a polling watcher is one second. + await Future.delayed(const Duration(seconds: 1)); - // result = await testHarness.callToolWithRetry(request); - // expect(result.isError, isNot(true)); - // expect(result.content, isEmpty); - // }); - // }); + result = await testHarness.callToolWithRetry(request); + expect(result.isError, isNot(true)); + expect( + result.content.single, + isA().having((t) => t.text, 'text', 'No errors'), + ); + }); + }); } From ea0d4bf29173597db9d9d91f26c9b007f6f9a85c Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Mon, 21 Apr 2025 22:40:54 +0000 Subject: [PATCH 3/6] revert json_rpc_2 constraint --- pkgs/dart_tooling_mcp_server/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/dart_tooling_mcp_server/pubspec.yaml b/pkgs/dart_tooling_mcp_server/pubspec.yaml index 6742cec..78d0e39 100644 --- a/pkgs/dart_tooling_mcp_server/pubspec.yaml +++ b/pkgs/dart_tooling_mcp_server/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: dds_service_extensions: ^2.0.1 devtools_shared: ^11.2.0 dtd: ^2.4.0 - json_rpc_2: ^4.0.0 + json_rpc_2: ^3.0.3 # TODO: Get this another way. language_server_protocol: git: From 492b0a1b8ad178034fd7c70afb6a4080fee51de6 Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Mon, 21 Apr 2025 22:42:20 +0000 Subject: [PATCH 4/6] drop analyzer dep --- pkgs/dart_tooling_mcp_server/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pkgs/dart_tooling_mcp_server/pubspec.yaml b/pkgs/dart_tooling_mcp_server/pubspec.yaml index 78d0e39..51b5b30 100644 --- a/pkgs/dart_tooling_mcp_server/pubspec.yaml +++ b/pkgs/dart_tooling_mcp_server/pubspec.yaml @@ -5,7 +5,6 @@ version: 0.1.0-wip environment: sdk: ^3.7.0 # The version of dart in the current flutter stable dependencies: - analyzer: ^7.3.0 async: ^2.13.0 dart_mcp: ^2.0.0 dds_service_extensions: ^2.0.1 From 5667e76c5452448ab3129af0733bf14048f56167 Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Tue, 22 Apr 2025 15:26:58 +0000 Subject: [PATCH 5/6] code review --- .../lib/src/mixins/analyzer.dart | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart b/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart index 03be8a9..a0e1fea 100644 --- a/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart +++ b/pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart @@ -98,19 +98,20 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { await initialized; log(LoggingLevel.warning, line, logger: 'DartLanguageServer'); }); - final channel = lspChannel(_lspServer.stdout, _lspServer.stdin); - _lspConnection = Peer(channel); - _lspConnection.registerMethod( - lsp.Method.textDocument_publishDiagnostics.toString(), - _handleDiagnostics, - ); - - _lspConnection.registerMethod(r'$/analyzerStatus', _handleAnalyzerStatus); - - _lspConnection.registerFallback((Parameters params) { - log(LoggingLevel.debug, 'fallback: ${params.method} - ${params.asMap}'); - }); + _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}', + ); + }); unawaited(_lspConnection.listen()); @@ -118,7 +119,7 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { lsp.InitializeResult? initializeResult; String? error; try { - /// Initialize with the server. + // Initialize with the server. initializeResult = lsp.InitializeResult.fromJson( (await _lspConnection.sendRequest( lsp.Method.initialize.toString(), @@ -138,6 +139,10 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { )) as Map, ); + log( + LoggingLevel.debug, + 'Completed initialize handshake analyzer lsp server', + ); } catch (e) { error = 'Error connecting to analyzer lsp server: $e'; } @@ -199,8 +204,8 @@ base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport { void _handleAnalyzerStatus(Parameters params) { final isAnalyzing = params.asMap['isAnalyzing'] as bool; if (isAnalyzing) { - /// Leave existing completer in place - we start with one so we don't - /// respond too early to the first analyze request. + // Leave existing completer in place - we start with one so we don't + // respond too early to the first analyze request. _doneAnalyzing ??= Completer(); } else { assert(_doneAnalyzing != null); From a38d599e9bef7ca2aae34292870b17facaa9fac0 Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Tue, 22 Apr 2025 16:47:27 +0000 Subject: [PATCH 6/6] wait for Editor.getDebugSessions to be registered before returning the FakeEditorExtension --- pkgs/dart_tooling_mcp_server/test/test_harness.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkgs/dart_tooling_mcp_server/test/test_harness.dart b/pkgs/dart_tooling_mcp_server/test/test_harness.dart index ec9fd80..ba93886 100644 --- a/pkgs/dart_tooling_mcp_server/test/test_harness.dart +++ b/pkgs/dart_tooling_mcp_server/test/test_harness.dart @@ -254,18 +254,18 @@ class FakeEditorExtension { int get nextId => ++_nextId; int _nextId = 0; - FakeEditorExtension(this.dtd, this.dtdProcess, this.dtdUri) { - _registerService(); - } + FakeEditorExtension._(this.dtd, this.dtdProcess, this.dtdUri); static Future connect() async { final dtdProcess = await TestProcess.start('dart', ['tooling-daemon']); final dtdUri = await _getDTDUri(dtdProcess); final dtd = await DartToolingDaemon.connect(Uri.parse(dtdUri)); - return FakeEditorExtension(dtd, dtdProcess, dtdUri); + final extension = FakeEditorExtension._(dtd, dtdProcess, dtdUri); + await extension._registerService(); + return extension; } - void _registerService() async { + Future _registerService() async { await dtd.registerService('Editor', 'getDebugSessions', (request) async { return GetDebugSessionsResponse( debugSessions: [