Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/dart_mcp_server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ on:
# Run CI on all PRs (against any branch) and on pushes to the main branch.
pull_request:
paths:
- '.github/workflows/dart_mcp_server.yaml'
- 'pkgs/dart_mcp_server/**'
- 'pkgs/dart_mcp/**'
- ".github/workflows/dart_mcp_server.yaml"
- "pkgs/dart_mcp_server/**"
- "pkgs/dart_mcp/**"
push:
branches: [ main ]
branches: [main]
paths:
- '.github/workflows/dart_mcp_server.yaml'
- 'pkgs/dart_mcp_server/**'
- 'pkgs/dart_mcp/**'
- ".github/workflows/dart_mcp_server.yaml"
- "pkgs/dart_mcp_server/**"
- "pkgs/dart_mcp/**"
schedule:
- cron: '0 0 * * 0' # weekly
- cron: "0 0 * * 0" # weekly

defaults:
run:
Expand Down Expand Up @@ -64,6 +64,6 @@ jobs:

# If this fails, you need to run 'dart tool/update_readme.dart'.
- run: dart tool/update_readme.dart
- run: git diff --exit-code README.md
- run: git diff --exit-code README.md || (echo 'README.md needs to be updated. Run "dart tool/update_readme.dart"' && false)

- run: dart test
1 change: 1 addition & 0 deletions pkgs/dart_mcp_server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
tends to hide nested text widgets which makes it difficult to find widgets
based on their text values.
* Add an `--exclude-tool` command line flag to exclude tools by name.
* Add the abillity to limit the output of `analyze_files` to a set of paths.

# 0.1.0 (Dart SDK 3.9.0)

Expand Down
2 changes: 1 addition & 1 deletion pkgs/dart_mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ For more information, see the official VS Code documentation for
| `run_tests` | Run tests | Run Dart or Flutter tests with an agent centric UX. ALWAYS use instead of `dart test` or `flutter test` shell commands. |
| `create_project` | Create project | Creates a new Dart or Flutter project. |
| `pub` | pub | Runs a pub command for the given project roots, like `dart pub get` or `flutter pub add`. |
| `analyze_files` | Analyze projects | Analyzes the entire project for errors. |
| `analyze_files` | Analyze projects | Analyzes specific paths, or the entire project, for errors. |
| `resolve_workspace_symbol` | Project search | 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. |
| `signature_help` | Signature help | Get signature help for an API being used at a given cursor position in a file. |
| `hover` | Hover information | Get hover information at a given cursor position in a file. This can include documentation, type information, etc for the text at that position. |
Expand Down
58 changes: 54 additions & 4 deletions pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ import 'package:meta/meta.dart';

import '../lsp/wire_format.dart';
import '../utils/analytics.dart';
import '../utils/cli_utils.dart';
import '../utils/constants.dart';
import '../utils/file_system.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
on ToolsSupport, LoggingSupport, RootsTrackingSupport, FileSystemSupport
implements SdkSupport {
/// The LSP server connection for the analysis server.
Peer? _lspConnection;
Expand Down Expand Up @@ -249,8 +251,54 @@ base mixin DartAnalyzerSupport
final errorResult = await _ensurePrerequisites(request);
if (errorResult != null) return errorResult;

var rootConfigs = (request.arguments?[ParameterNames.roots] as List?)
?.cast<Map<String, Object?>>();
final allRoots = await roots;

if (rootConfigs != null && rootConfigs.isEmpty) {
// Have to have at least one root set.
return noRootsSetResponse;
}

// Default to use the known roots if none were specified.
rootConfigs ??= [
for (final root in allRoots) {ParameterNames.root: root.uri},
];

final requestedUris = <Uri>{};
for (final rootConfig in rootConfigs) {
final validated = validateRootConfig(
rootConfig,
knownRoots: allRoots,
fileSystem: fileSystem,
);

if (validated.errorResult != null) {
return errorResult!;
}

final rootUri = Uri.parse(validated.root!.uri);

if (validated.paths != null && validated.paths!.isNotEmpty) {
for (final path in validated.paths!) {
requestedUris.add(rootUri.resolve(path));
}
} else {
requestedUris.add(rootUri);
}
}

final entries = diagnostics.entries.where((entry) {
final entryPath = entry.key.toFilePath();
return requestedUris.any((uri) {
final requestedPath = uri.toFilePath();
return fileSystem.path.equals(requestedPath, entryPath) ||
fileSystem.path.isWithin(requestedPath, entryPath);
});
});

final messages = <Content>[];
for (var entry in diagnostics.entries) {
for (var entry in entries) {
for (var diagnostic in entry.value) {
final diagnosticJson = diagnostic.toJson();
diagnosticJson[ParameterNames.uri] = entry.key.toString();
Expand Down Expand Up @@ -411,8 +459,10 @@ base mixin DartAnalyzerSupport
@visibleForTesting
static final analyzeFilesTool = Tool(
name: 'analyze_files',
description: 'Analyzes the entire project for errors.',
inputSchema: Schema.object(),
description: 'Analyzes specific paths, or the entire project, for errors.',
inputSchema: Schema.object(
properties: {ParameterNames.roots: rootsSchema(supportsPaths: true)},
),
annotations: ToolAnnotations(title: 'Analyze projects', readOnlyHint: true),
);

Expand Down
2 changes: 1 addition & 1 deletion pkgs/dart_mcp_server/lib/src/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ final class DartMCPServer extends MCPServer
ResourcesSupport,
RootsTrackingSupport,
RootsFallbackSupport,
DartAnalyzerSupport,
DashCliSupport,
DartAnalyzerSupport,
PubSupport,
PubDevSupport,
DartToolingDaemonSupport,
Expand Down
99 changes: 99 additions & 0 deletions pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,105 @@ Future<CallToolResult> runCommandInRoot(
);
}

/// Validates a root argument given via [rootConfig], ensuring that it falls
/// under one of the [knownRoots], and that all `paths` arguments are also under
/// the given root.
///
/// Returns a root on success, equal to the given root (but this could be a
/// subdirectory of one of the [knownRoots]), as well as any paths that were
/// validated.
///
/// If no [ParameterNames.paths] are provided, then the [defaultPaths] will be
/// used, if present. Otherwise no paths are validated or will be returned.
///
/// On failure, returns a [CallToolResult].
({Root? root, List<String>? paths, CallToolResult? errorResult})
validateRootConfig(
Map<String, Object?>? rootConfig, {
List<String>? defaultPaths,
required FileSystem fileSystem,
required List<Root> knownRoots,
}) {
final rootUriString = rootConfig?[ParameterNames.root] as String?;
if (rootUriString == null) {
// This shouldn't happen based on the schema, but handle defensively.
return (
root: null,
paths: null,
errorResult: CallToolResult(
content: [
TextContent(text: 'Invalid root configuration: missing `root` key.'),
],
isError: true,
)..failureReason ??= CallToolFailureReason.noRootGiven,
);
}
final rootUri = Uri.parse(rootUriString);
if (rootUri.scheme != 'file') {
return (
root: null,
paths: null,
errorResult: CallToolResult(
content: [
TextContent(
text:
'Only file scheme uris are allowed for roots, but got '
'$rootUri',
),
],
isError: true,
)..failureReason ??= CallToolFailureReason.invalidRootScheme,
);
}

final knownRoot = knownRoots.firstWhereOrNull(
(root) => _isUnderRoot(root, rootUriString, fileSystem),
);
if (knownRoot == null) {
return (
root: null,
paths: null,
errorResult: CallToolResult(
content: [
TextContent(
text:
'Invalid root $rootUriString, must be under one of the '
'registered project roots:\n\n${knownRoots.join('\n')}',
),
],
isError: true,
)..failureReason ??= CallToolFailureReason.invalidRootPath,
);
}
final root = Root(uri: rootUriString);

final paths =
(rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
defaultPaths;
if (paths != null) {
final invalidPaths = paths.where(
(path) => !_isUnderRoot(root, path, fileSystem),
);
if (invalidPaths.isNotEmpty) {
return (
root: null,
paths: null,
errorResult: CallToolResult(
content: [
TextContent(
text:
'Paths are not allowed to escape their project root:\n'
'${invalidPaths.join('\n')}',
),
],
isError: true,
)..failureReason ??= CallToolFailureReason.invalidPath,
);
}
}
return (root: root, paths: paths, errorResult: null);
}

/// Returns 'dart' or 'flutter' based on the pubspec contents.
///
/// Throws an [ArgumentError] if there is no pubspec.
Expand Down
5 changes: 3 additions & 2 deletions pkgs/dart_mcp_server/test/test_harness.dart
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ class TestHarness {
final result = await mcpServerConnection.callTool(request);
expect(
result.isError,
expectError ? true : isNot(true),
expectError ? true : isNot(isTrue),
reason: result.content.join('\n'),
);
return result;
Expand All @@ -185,11 +185,12 @@ class TestHarness {
Future<CallToolResult> callToolWithRetry(
CallToolRequest request, {
int maxTries = 5,
bool expectError = false,
}) async {
var tryCount = 0;
while (true) {
try {
return await callTool(request);
return await callTool(request, expectError: expectError);
} catch (_) {
if (tryCount++ >= maxTries) rethrow;
}
Expand Down
Loading
Loading