Skip to content
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
50 changes: 46 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,46 @@ 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) {
// Empty list of roots means do nothing.
return CallToolResult(content: [TextContent(text: 'No errors')]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should either return errors for the entire project or return an error response. This isn't really a valid request?

}

// 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 rootUriString = rootConfig[ParameterNames.root] as String;
final rootUri = Uri.parse(rootUriString);
final paths = (rootConfig[ParameterNames.paths] as List?)?.cast<String>();

if (paths != null && paths.isNotEmpty) {
for (final path in 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 +451,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
6 changes: 3 additions & 3 deletions pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Future<CallToolResult> runCommandInRoot(
}

final root = knownRoots.firstWhereOrNull(
(root) => _isUnderRoot(root, rootUriString, fileSystem),
(root) => isUnderRoot(root, rootUriString, fileSystem),
);
if (root == null) {
return CallToolResult(
Expand Down Expand Up @@ -203,7 +203,7 @@ Future<CallToolResult> runCommandInRoot(
(rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
defaultPaths;
final invalidPaths = paths.where(
(path) => !_isUnderRoot(root, path, fileSystem),
(path) => !isUnderRoot(root, path, fileSystem),
);
if (invalidPaths.isNotEmpty) {
return CallToolResult(
Expand Down Expand Up @@ -273,7 +273,7 @@ Future<String> defaultCommandForRoot(
/// Returns whether [uri] is under or exactly equal to [root].
///
/// Relative uris will always be under [root] unless they escape it with `../`.
bool _isUnderRoot(Root root, String uri, FileSystem fileSystem) {
bool isUnderRoot(Root root, String uri, FileSystem fileSystem) {
// This normalizes the URI to ensure it is treated as a directory (for example
// ensures it ends with a trailing slash).
final rootUri = fileSystem.directory(Uri.parse(root.uri)).uri;
Expand Down
252 changes: 252 additions & 0 deletions pkgs/dart_mcp_server/test/tools/analyzer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,258 @@ void main() {
);
});

test('can analyze a project with multiple errors (no paths)', () async {
final example = d.dir('example', [
d.file('main.dart', 'void main() => 1 + "2";'),
d.file('other.dart', 'void other() => foo;'),
]);
await example.create();
final exampleRoot = testHarness.rootForPath(example.io.path);
testHarness.mcpClient.addRoot(exampleRoot);

await pumpEventQueue();

final request = CallToolRequest(name: analyzeTool.name);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(result.content, hasLength(2));
expect(
result.content,
containsAll([
isA<TextContent>().having(
(t) => t.text,
'text',
contains("Undefined name 'foo'"),
),
isA<TextContent>().having(
(t) => t.text,
'text',
contains(
"The argument type 'String' can't be assigned to the parameter "
"type 'num'.",
),
),
]),
);
});

test('can analyze a specific file', () async {
final example = d.dir('example', [
d.file('main.dart', 'void main() => 1 + "2";'),
d.file('other.dart', 'void other() => foo;'),
]);
await example.create();
final exampleRoot = testHarness.rootForPath(example.io.path);
testHarness.mcpClient.addRoot(exampleRoot);

await pumpEventQueue();

final request = CallToolRequest(
name: analyzeTool.name,
arguments: {
ParameterNames.roots: [
{
ParameterNames.root: exampleRoot.uri,
ParameterNames.paths: ['main.dart'],
},
],
},
);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(result.content, hasLength(1));
expect(
result.content.single,
isA<TextContent>().having(
(t) => t.text,
'text',
contains(
"The argument type 'String' can't be assigned to the parameter "
"type 'num'.",
),
),
);
});

test('can analyze a specific directory', () async {
final example = d.dir('example', [
d.file('main.dart', 'void main() => 1 + "2";'),
d.dir('sub', [d.file('other.dart', 'void other() => foo;')]),
]);
await example.create();
final exampleRoot = testHarness.rootForPath(example.io.path);
testHarness.mcpClient.addRoot(exampleRoot);

await pumpEventQueue();

final request = CallToolRequest(
name: analyzeTool.name,
arguments: {
ParameterNames.roots: [
{
ParameterNames.root: exampleRoot.uri,
ParameterNames.paths: ['sub'],
},
],
},
);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(result.content, hasLength(1));
expect(
result.content.single,
isA<TextContent>().having(
(t) => t.text,
'text',
contains("Undefined name 'foo'"),
),
);
});

test('handles a non-existent path', () async {
final example = d.dir('example', [
d.file('main.dart', 'void main() => 1 + "2";'),
]);
await example.create();
final exampleRoot = testHarness.rootForPath(example.io.path);
testHarness.mcpClient.addRoot(exampleRoot);

await pumpEventQueue();

final request = CallToolRequest(
name: analyzeTool.name,
arguments: {
ParameterNames.roots: [
{
ParameterNames.root: exampleRoot.uri,
ParameterNames.paths: ['not_a_real_file.dart'],
},
],
},
);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(
result.content.single,
isA<TextContent>().having((t) => t.text, 'text', 'No errors'),
);
});

test('handles an empty paths list for a root', () async {
final example = d.dir('example', [
d.file('main.dart', 'void main() => 1 + "2";'),
]);
await example.create();
final exampleRoot = testHarness.rootForPath(example.io.path);
testHarness.mcpClient.addRoot(exampleRoot);

await pumpEventQueue();

final request = CallToolRequest(
name: analyzeTool.name,
arguments: {
ParameterNames.roots: [
{
ParameterNames.root: exampleRoot.uri,
ParameterNames.paths: <String>[], // Empty paths
},
],
},
);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(result.content, hasLength(1));
expect(
result.content.single,
isA<TextContent>().having(
(t) => t.text,
'text',
contains(
"The argument type 'String' can't be assigned to the parameter "
"type 'num'.",
),
),
);
});

test('handles an empty roots list', () async {
// We still need a root registered with the server so that the
// prerequisites check passes.
final example = d.dir('example', [
d.file('main.dart', 'void main() => 1;'),
]);
await example.create();
final exampleRoot = testHarness.rootForPath(example.io.path);
testHarness.mcpClient.addRoot(exampleRoot);
await pumpEventQueue();

final request = CallToolRequest(
name: analyzeTool.name,
arguments: {ParameterNames.roots: []},
);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(
result.content.single,
isA<TextContent>().having((t) => t.text, 'text', 'No errors'),
);
});

test('can analyze files in multiple roots', () async {
final projectA = d.dir('project_a', [
d.file('main.dart', 'void main() => 1 + "a";'),
]);
await projectA.create();
final projectARoot = testHarness.rootForPath(projectA.io.path);
testHarness.mcpClient.addRoot(projectARoot);

final projectB = d.dir('project_b', [
d.file('other.dart', 'void other() => foo;'),
]);
await projectB.create();
final projectBRoot = testHarness.rootForPath(projectB.io.path);
testHarness.mcpClient.addRoot(projectBRoot);

await pumpEventQueue();

final request = CallToolRequest(
name: analyzeTool.name,
arguments: {
ParameterNames.roots: [
{
ParameterNames.root: projectARoot.uri,
ParameterNames.paths: ['main.dart'],
},
{
ParameterNames.root: projectBRoot.uri,
ParameterNames.paths: ['other.dart'],
},
],
},
);
final result = await testHarness.callToolWithRetry(request);
expect(result.isError, isNot(true));
expect(result.content, hasLength(2));
expect(
result.content,
containsAll([
isA<TextContent>().having(
(t) => t.text,
'text',
contains(
"The argument type 'String' can't be assigned to the "
"parameter type 'num'.",
),
),
isA<TextContent>().having(
(t) => t.text,
'text',
contains("Undefined name 'foo'"),
),
]),
);
});

test('can look up symbols in a workspace', () async {
final example = d.dir('lib', [
d.file('awesome_class.dart', 'class MyAwesomeClass {}'),
Expand Down
Loading