blob: eb0f2fe467618e5079b1810baf77c8dc8d01ad9b [file] [log] [blame]
// 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:collection';
import 'dart:convert';
import 'dart:io';
import 'package:dart_mcp/server.dart';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:language_server_protocol/protocol_generated.dart' as lsp;
import 'package:meta/meta.dart';
import '../lsp/wire_format.dart';
import '../utils/constants.dart';
import '../utils/sdk.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, RootsTrackingSupport
implements SdkSupport {
/// The LSP server connection for the analysis server.
Peer? _lspConnection;
/// The actual process for the LSP server.
Process? _lspServer;
/// The current diagnostics for a given file.
Map<Uri, List<lsp.Diagnostic>> diagnostics = {};
/// If currently analyzing, a completer which will be completed once analysis
/// is over.
Completer<void>? _doneAnalyzing = Completer();
/// The current LSP workspace folder state.
HashSet<lsp.WorkspaceFolder> _currentWorkspaceFolders =
HashSet<lsp.WorkspaceFolder>(
equals: (a, b) => a.uri == b.uri,
hashCode: (a) => a.uri.hashCode,
);
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) async {
// This should come first, assigns `clientCapabilities`.
final result = await super.initialize(request);
// We check for requirements and store a message to log after initialization
// if some requirement isn't satisfied.
final unsupportedReasons = <String>[
if (!supportsRoots)
'Project analysis requires the "roots" capability which is not '
'supported. Analysis tools have been disabled.',
if (sdk.dartSdkPath == null)
'Project analysis requires a Dart SDK but none was given. Analysis '
'tools have been disabled.',
];
if (unsupportedReasons.isEmpty) {
if (await _initializeAnalyzerLspServer() case final failedReason?) {
unsupportedReasons.add(failedReason);
}
}
if (unsupportedReasons.isEmpty) {
registerTool(analyzeFilesTool, _analyzeFiles);
registerTool(resolveWorkspaceSymbolTool, _resolveWorkspaceSymbol);
registerTool(signatureHelpTool, _signatureHelp);
registerTool(hoverTool, _hover);
}
// Don't call any methods on the client until we are fully initialized
// (even logging).
unawaited(
initialized.then((_) {
if (unsupportedReasons.isNotEmpty) {
log(LoggingLevel.warning, unsupportedReasons.join('\n'));
}
}),
);
return result;
}
/// Initializes the analyzer lsp server.
///
/// On success, returns `null`.
///
/// On failure, returns a reason for the failure.
Future<String?> _initializeAnalyzerLspServer() async {
final lspServer = await Process.start(sdk.dartExecutablePath, [
'language-server',
// Required even though it is documented as the default.
// https://github.com/dart-lang/sdk/issues/60574
'--protocol',
'lsp',
// Uncomment these to log the analyzer traffic.
// '--protocol-traffic-log',
// 'language-server-protocol.log',
]);
_lspServer = lspServer;
lspServer.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) async {
await initialized;
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}',
);
});
_lspConnection = lspConnection;
unawaited(lspConnection.listen());
log(LoggingLevel.debug, 'Connecting to analyzer lsp server');
lsp.InitializeResult? initializeResult;
String? error;
try {
// Initialize with the server.
initializeResult = lsp.InitializeResult.fromJson(
(await lspConnection.sendRequest(
lsp.Method.initialize.toString(),
lsp.InitializeParams(
capabilities: lsp.ClientCapabilities(
workspace: lsp.WorkspaceClientCapabilities(
diagnostics: lsp.DiagnosticWorkspaceClientCapabilities(
refreshSupport: true,
),
symbol: lsp.WorkspaceSymbolClientCapabilities(
symbolKind:
lsp.WorkspaceSymbolClientCapabilitiesSymbolKind(
valueSet: [
lsp.SymbolKind.Array,
lsp.SymbolKind.Boolean,
lsp.SymbolKind.Class,
lsp.SymbolKind.Constant,
lsp.SymbolKind.Constructor,
lsp.SymbolKind.Enum,
lsp.SymbolKind.EnumMember,
lsp.SymbolKind.Event,
lsp.SymbolKind.Field,
lsp.SymbolKind.File,
lsp.SymbolKind.Function,
lsp.SymbolKind.Interface,
lsp.SymbolKind.Key,
lsp.SymbolKind.Method,
lsp.SymbolKind.Module,
lsp.SymbolKind.Namespace,
lsp.SymbolKind.Null,
lsp.SymbolKind.Number,
lsp.SymbolKind.Obj,
lsp.SymbolKind.Operator,
lsp.SymbolKind.Package,
lsp.SymbolKind.Property,
lsp.SymbolKind.Str,
lsp.SymbolKind.Struct,
lsp.SymbolKind.TypeParameter,
lsp.SymbolKind.Variable,
],
),
),
),
textDocument: lsp.TextDocumentClientCapabilities(
hover: lsp.HoverClientCapabilities(),
publishDiagnostics:
lsp.PublishDiagnosticsClientCapabilities(),
signatureHelp: lsp.SignatureHelpClientCapabilities(),
),
),
).toJson(),
))
as Map<String, Object?>,
);
log(
LoggingLevel.debug,
'Completed initialize handshake analyzer lsp server',
);
} catch (e) {
error = 'Error connecting to analyzer lsp server: $e';
}
if (initializeResult != null) {
// Checks that we can set workspaces on the LSP server.
final workspaceSupport =
initializeResult.capabilities.workspace?.workspaceFolders;
if (workspaceSupport?.supported != true) {
error ??= 'Workspaces are not supported by the LSP server';
} else if (workspaceSupport?.changeNotifications?.valueEquals(true) !=
true) {
error ??=
'Workspace change notifications are not supported by the LSP '
'server';
}
// Checks that we resolve workspace symbols.
final workspaceSymbolProvider =
initializeResult.capabilities.workspaceSymbolProvider;
final symbolProvidersSupported =
workspaceSymbolProvider != null &&
workspaceSymbolProvider.map(
(b) => b,
(options) => options.resolveProvider == true,
);
if (!symbolProvidersSupported) {
error ??=
'Workspace symbol resolution is not supported by the LSP server';
}
}
if (error != null) {
lspServer.kill();
await lspConnection.close();
} else {
lspConnection.sendNotification(
lsp.Method.initialized.toString(),
lsp.InitializedParams().toJson(),
);
}
return error;
}
@override
Future<void> shutdown() async {
await super.shutdown();
_lspServer?.kill();
await _lspConnection?.close();
}
/// Implementation of the [analyzeFilesTool], analyzes all the files in all
/// workspace dirs.
///
/// Waits for any pending analysis before returning.
Future<CallToolResult> _analyzeFiles(CallToolRequest request) async {
final errorResult = await _ensurePrerequisites(request);
if (errorResult != null) return errorResult;
final messages = <Content>[];
for (var entry in diagnostics.entries) {
for (var diagnostic in entry.value) {
final diagnosticJson = diagnostic.toJson();
diagnosticJson[ParameterNames.uri] = entry.key.toString();
messages.add(TextContent(text: jsonEncode(diagnosticJson)));
}
}
if (messages.isEmpty) {
messages.add(TextContent(text: 'No errors'));
}
return CallToolResult(content: messages);
}
/// Implementation of the [resolveWorkspaceSymbolTool], resolves a given
/// symbol or symbols in a workspace.
Future<CallToolResult> _resolveWorkspaceSymbol(
CallToolRequest request,
) async {
final errorResult = await _ensurePrerequisites(request);
if (errorResult != null) return errorResult;
final query = request.arguments![ParameterNames.query] as String;
final result = await _lspConnection!.sendRequest(
lsp.Method.workspace_symbol.toString(),
lsp.WorkspaceSymbolParams(query: query).toJson(),
);
return CallToolResult(content: [TextContent(text: jsonEncode(result))]);
}
/// Implementation of the [signatureHelpTool], get signature help for a given
/// position in a file.
Future<CallToolResult> _signatureHelp(CallToolRequest request) async {
final errorResult = await _ensurePrerequisites(request);
if (errorResult != null) return errorResult;
final uri = Uri.parse(request.arguments![ParameterNames.uri] as String);
final position = lsp.Position(
line: request.arguments![ParameterNames.line] as int,
character: request.arguments![ParameterNames.column] as int,
);
final result = await _lspConnection!.sendRequest(
lsp.Method.textDocument_signatureHelp.toString(),
lsp.SignatureHelpParams(
textDocument: lsp.TextDocumentIdentifier(uri: uri),
position: position,
).toJson(),
);
return CallToolResult(content: [TextContent(text: jsonEncode(result))]);
}
/// Implementation of the [hoverTool], get hover information for a given
/// position in a file.
Future<CallToolResult> _hover(CallToolRequest request) async {
final errorResult = await _ensurePrerequisites(request);
if (errorResult != null) return errorResult;
final uri = Uri.parse(request.arguments![ParameterNames.uri] as String);
final position = lsp.Position(
line: request.arguments![ParameterNames.line] as int,
character: request.arguments![ParameterNames.column] as int,
);
final result = await _lspConnection!.sendRequest(
lsp.Method.textDocument_hover.toString(),
lsp.HoverParams(
textDocument: lsp.TextDocumentIdentifier(uri: uri),
position: position,
).toJson(),
);
return CallToolResult(content: [TextContent(text: jsonEncode(result))]);
}
/// Ensures that all prerequisites for any analysis task are met.
///
/// Returns an error response if any prerequisite is not met, otherwise
/// returns `null`.
Future<CallToolResult?> _ensurePrerequisites(CallToolRequest request) async {
final roots = await this.roots;
if (roots.isEmpty) {
return noRootsSetResponse;
}
await _doneAnalyzing?.future;
return null;
}
/// 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<void>();
} 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<String, Object?>,
);
diagnostics[diagnosticParams.uri] = diagnosticParams.diagnostics;
log(LoggingLevel.debug, {
ParameterNames.uri: diagnosticParams.uri,
'diagnostics': diagnosticParams.diagnostics
.map((d) => d.toJson())
.toList(),
});
}
/// Update the LSP workspace dirs when our workspace [Root]s change.
@override
Future<void> updateRoots() async {
await super.updateRoots();
unawaited(() async {
final newRoots = await roots;
final oldWorkspaceFolders = _currentWorkspaceFolders;
final newWorkspaceFolders = _currentWorkspaceFolders =
HashSet<lsp.WorkspaceFolder>(
equals: (a, b) => a.uri == b.uri,
hashCode: (a) => a.uri.hashCode,
)..addAll(newRoots.map((r) => r.asWorkspaceFolder));
final 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
// stabilized which can result in empty diffs.
if (added.isEmpty && removed.isEmpty) {
return;
}
final event = lsp.WorkspaceFoldersChangeEvent(
added: added,
removed: removed,
);
log(
LoggingLevel.debug,
() => 'Notifying of workspace root change: ${event.toJson()}',
);
_lspConnection!.sendNotification(
lsp.Method.workspace_didChangeWorkspaceFolders.toString(),
lsp.DidChangeWorkspaceFoldersParams(event: event).toJson(),
);
}());
}
@visibleForTesting
static final analyzeFilesTool = Tool(
name: 'analyze_files',
description: 'Analyzes the entire project for errors.',
inputSchema: Schema.object(),
annotations: ToolAnnotations(title: 'Analyze projects', readOnlyHint: true),
);
@visibleForTesting
static final resolveWorkspaceSymbolTool = Tool(
name: 'resolve_workspace_symbol',
description:
'Look up a symbol or symbols in all workspaces by name. Can be used '
'to validate that a symbol exists or discover small spelling '
'mistakes, since the search is fuzzy.',
inputSchema: Schema.object(
properties: {
ParameterNames.query: Schema.string(
description:
'Queries are matched based on a case-insensitive partial name '
'match, and do not support complex pattern matching, regexes, '
'or scoped lookups.',
),
},
description:
'Returns all close matches to the query, with their names '
'and locations. Be sure to check the name of the responses to ensure '
'it looks like the thing you were searching for.',
required: [ParameterNames.query],
),
annotations: ToolAnnotations(title: 'Project search', readOnlyHint: true),
);
@visibleForTesting
static final signatureHelpTool = Tool(
name: 'signature_help',
description:
'Get signature help for an API being used at a given cursor '
'position in a file.',
inputSchema: _locationSchema,
annotations: ToolAnnotations(title: 'Signature help', readOnlyHint: true),
);
@visibleForTesting
static final hoverTool = Tool(
name: 'hover',
description:
'Get hover information at a given cursor position in a file. This can '
'include documentation, type information, etc for the text at that '
'position.',
inputSchema: _locationSchema,
annotations: ToolAnnotations(
title: 'Hover information',
readOnlyHint: true,
),
);
@visibleForTesting
static final noRootsSetResponse = CallToolResult(
isError: true,
content: [
TextContent(
text:
'No roots set. At least one root must be set in order to use this '
'tool.',
),
],
);
}
/// Common schema for tools that require a file URI, line, and column.
final _locationSchema = Schema.object(
properties: {
ParameterNames.uri: Schema.string(description: 'The URI of the file.'),
ParameterNames.line: Schema.int(
description: 'The zero-based line number of the cursor position.',
),
ParameterNames.column: Schema.int(
description: 'The zero-based column number of the cursor position.',
),
},
required: [ParameterNames.uri, ParameterNames.line, ParameterNames.column],
);
extension on Root {
/// Converts a [Root] to an [lsp.WorkspaceFolder].
lsp.WorkspaceFolder get asWorkspaceFolder =>
lsp.WorkspaceFolder(name: name ?? '', uri: Uri.parse(uri));
}