| // 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 'package:dart_mcp/server.dart'; |
| import 'package:dart_mcp_server/src/mixins/analyzer.dart'; |
| import 'package:dart_mcp_server/src/utils/names.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:test/test.dart'; |
| import 'package:test_descriptor/test_descriptor.dart' as d; |
| import 'package:unified_analytics/unified_analytics.dart'; |
| |
| import '../test_harness.dart'; |
| |
| void main() { |
| late TestHarness testHarness; |
| late FakeAnalytics analytics; |
| |
| // TODO: Use setUpAll, currently this fails due to an apparent TestProcess |
| // issue. |
| setUp(() async { |
| // These must run in process to inspect the analytics. |
| testHarness = await TestHarness.start(inProcess: true); |
| analytics = |
| testHarness.serverConnectionPair.server!.analytics as FakeAnalytics; |
| }); |
| |
| group('analyzer tools', () { |
| late Tool analyzeTool; |
| |
| setUp(() async { |
| final tools = (await testHarness.mcpServerConnection.listTools()).tools; |
| analyzeTool = tools.singleWhere( |
| (t) => t.name == DartAnalyzerSupport.analyzeFilesTool.name, |
| ); |
| }); |
| |
| test('can analyze and re-analyze after changes', () 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); |
| |
| // Allow the notification to propagate, and the server to ask for the new |
| // list of roots. |
| await pumpEventQueue(); |
| |
| final request = CallToolRequest(name: analyzeTool.name); |
| var result = await testHarness.callToolWithRetry(request); |
| expect(result.isError, isNot(true)); |
| expect(result.content, [ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root ${exampleRoot.uri}'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains( |
| "error • main.dart:1:20 • 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<void>.delayed(const Duration(seconds: 1)); |
| |
| result = await testHarness.callToolWithRetry(request); |
| expect(result.isError, isNot(true)); |
| expect(result.content, [ |
| isA<TextContent>().having((t) => t.text, 'text', 'No errors'), |
| ]); |
| }); |
| |
| 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, |
| containsAll([ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root ${exampleRoot.uri}'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains("error • other.dart:1:17 • Undefined name 'foo'"), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains( |
| "error • main.dart:1:20 • The argument type 'String' can't be " |
| "assigned to the parameter type 'num'.", |
| ), |
| ), |
| ]), |
| ); |
| }); |
| |
| test('can analyze a project when roots use schemeless paths (Cursor ' |
| 'regression #506)', () async { |
| final example = d.dir('example', [ |
| d.file('main.dart', 'void main() => 1 + "2";'), |
| ]); |
| await example.create(); |
| |
| // Instead of file:// URI, use the raw absolute path as the client root. |
| final schemelessRoot = Root(uri: example.io.path, name: 'example'); |
| testHarness.mcpClient.addRoot(schemelessRoot); |
| |
| await pumpEventQueue(); |
| |
| final request = CallToolRequest( |
| name: analyzeTool.name, |
| arguments: { |
| ParameterNames.roots: [ |
| { |
| // Request uses file:// uri which doesn't match the root as |
| // provided in roots/list, but should normalize to the same URI. |
| ParameterNames.root: Uri.file(example.io.path).toString(), |
| }, |
| ], |
| }, |
| ); |
| final result = await testHarness.callTool(request); |
| expect(result.isError, isNot(true)); |
| |
| final expectedUri = Uri.file(example.io.path).toString(); |
| expect(result.content, [ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root $expectedUri'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains( |
| "error • main.dart:1:20 • 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, [ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root ${exampleRoot.uri}'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains( |
| "error • main.dart:1:20 • 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, [ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains("error • other.dart:1:17 • 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, [ |
| 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, [ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root ${exampleRoot.uri}'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains( |
| "error • main.dart:1:20 • 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); |
| // PumpEventQueue isn't sufficient for this test for some reason. |
| await Future<void>.delayed(const Duration(milliseconds: 100)); |
| |
| final request = CallToolRequest( |
| name: analyzeTool.name, |
| arguments: {ParameterNames.roots: []}, |
| ); |
| final result = await testHarness.callToolWithRetry( |
| request, |
| expectError: true, |
| ); |
| expect(result.isError, isTrue); |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| 'A list of roots was provided, but it was empty. Either omit the ' |
| '`roots` parameter to use the default roots, or provide a ' |
| 'non-empty list of roots.', |
| ), |
| ); |
| }); |
| |
| test('handles invalid 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(); |
| |
| // We won't see the expected error until the roots notification |
| // propagates to the server. |
| await callWithRetry(() async { |
| final request = CallToolRequest( |
| name: analyzeTool.name, |
| arguments: { |
| ParameterNames.roots: [ |
| {'root': 'file:///invalid/root'}, |
| ], |
| }, |
| ); |
| final result = await testHarness.callTool(request, expectError: true); |
| expect(result.isError, isTrue); |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| allOf( |
| contains( |
| 'Invalid root file:///invalid/root, must be under one of the ' |
| 'registered project roots:', |
| ), |
| contains(example.io.uri.toString()), |
| ), |
| ), |
| ); |
| }); |
| }); |
| |
| 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'], |
| }, |
| ], |
| }, |
| ); |
| // It may take a bit for the errors to show up. |
| await callWithRetry(() async { |
| final result = await testHarness.callTool(request); |
| expect(result.isError, isNot(true)); |
| expect(result.content, hasLength(4)); |
| expect( |
| result.content, |
| containsAll([ |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root ${projectARoot.uri}\n'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('# Diagnostics for root ${projectBRoot.uri}\n'), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains( |
| "error • main.dart:1:20 • The argument type 'String' " |
| "can't be assigned to the parameter type 'num'.", |
| ), |
| ), |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains("error • other.dart:1:17 • Undefined name 'foo'"), |
| ), |
| ]), |
| ); |
| }, maxTries: 10); |
| }); |
| |
| test('can look up symbols in a workspace', () async { |
| final example = d.dir('lib', [ |
| d.file('awesome_class.dart', 'class MyAwesomeClass {}'), |
| ]); |
| await example.create(); |
| final exampleRoot = testHarness.rootForPath(example.io.path); |
| testHarness.mcpClient.addRoot(exampleRoot); |
| await pumpEventQueue(); |
| |
| final result = await testHarness.callToolWithRetry( |
| CallToolRequest( |
| name: DartAnalyzerSupport.lspTool.name, |
| arguments: { |
| ParameterNames.command: LspCommands.resolveWorkspaceSymbol, |
| ParameterNames.query: 'MyAwesomeClass', |
| }, |
| ), |
| ); |
| expect(result.isError, isNot(true)); |
| |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('awesome_class.dart'), |
| ), |
| ); |
| }); |
| |
| test('can get signature help', () async { |
| final example = d.dir('example', [ |
| d.file('main.dart', ''' |
| void main() { |
| printIt(x: 1); |
| } |
| |
| /// Just prints [x]. |
| void printIt({required int x}) { |
| print(x); |
| } |
| '''), |
| ]); |
| await example.create(); |
| final exampleRoot = testHarness.rootForPath(example.io.path); |
| testHarness.mcpClient.addRoot(exampleRoot); |
| |
| await pumpEventQueue(); |
| |
| final result = await testHarness.callToolWithRetry( |
| CallToolRequest( |
| name: DartAnalyzerSupport.lspTool.name, |
| arguments: { |
| ParameterNames.command: LspCommands.signatureHelp, |
| ParameterNames.uri: p.join(exampleRoot.uri, 'main.dart'), |
| ParameterNames.line: 1, |
| ParameterNames.column: 12, |
| }, |
| ), |
| ); |
| expect(result.isError, isNot(true)); |
| |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| allOf( |
| contains('Just prints [x].'), // From the doc comment |
| contains('printIt({required int x})'), // The actual signature |
| ), |
| ), |
| ); |
| }); |
| |
| test('can get hover information', () async { |
| final example = d.dir('example', [ |
| d.file('main.dart', ''' |
| void main() { |
| printIt(x: 1); |
| } |
| |
| /// Just prints [x]. |
| void printIt({required int x}) { |
| print(x); |
| } |
| '''), |
| ]); |
| await example.create(); |
| final exampleRoot = testHarness.rootForPath(example.io.path); |
| testHarness.mcpClient.addRoot(exampleRoot); |
| await pumpEventQueue(); |
| |
| final result = await testHarness.callToolWithRetry( |
| CallToolRequest( |
| name: DartAnalyzerSupport.lspTool.name, |
| arguments: { |
| ParameterNames.command: LspCommands.hover, |
| ParameterNames.uri: p.join(exampleRoot.uri, 'main.dart'), |
| ParameterNames.line: 1, |
| ParameterNames.column: 4, |
| }, |
| ), |
| ); |
| expect(result.isError, isNot(true)); |
| |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| allOf( |
| contains('Just prints [x].'), // Doc comment |
| contains('void printIt({required int x})'), // Function signature |
| contains('void Function({required int x})'), // The type of it |
| ), |
| ), |
| ); |
| }); |
| |
| test('cannot analyze without roots set', () async { |
| final result = await testHarness.callTool( |
| CallToolRequest(name: DartAnalyzerSupport.analyzeFilesTool.name), |
| expectError: true, |
| ); |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('No roots set'), |
| ), |
| ); |
| }); |
| |
| test('cannot look up symbols without roots set', () async { |
| final result = await testHarness.callTool( |
| CallToolRequest( |
| name: DartAnalyzerSupport.lspTool.name, |
| arguments: { |
| ParameterNames.command: LspCommands.resolveWorkspaceSymbol, |
| ParameterNames.query: 'DartAnalyzerSupport', |
| }, |
| ), |
| expectError: true, |
| ); |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('No roots set'), |
| ), |
| ); |
| }); |
| |
| test('cannot get hover information without roots set', () async { |
| final result = await testHarness.callTool( |
| CallToolRequest( |
| name: DartAnalyzerSupport.lspTool.name, |
| arguments: { |
| ParameterNames.command: LspCommands.hover, |
| ParameterNames.uri: 'file:///any/file.dart', |
| ParameterNames.line: 0, |
| ParameterNames.column: 0, |
| }, |
| ), |
| expectError: true, |
| ); |
| expect( |
| result.content.single, |
| isA<TextContent>().having( |
| (t) => t.text, |
| 'text', |
| contains('No roots set'), |
| ), |
| ); |
| }); |
| |
| test('send analytics', () 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(); |
| |
| await testHarness.callToolWithRetry( |
| CallToolRequest(name: DartAnalyzerSupport.analyzeFilesTool.name), |
| ); |
| expect( |
| analytics.sentEvents.last.eventData, |
| allOf( |
| isNot(contains(AnalysisMetrics.applyFixesTimeMsKey)), |
| containsPair(AnalysisMetrics.analyzerReadyTimeMsKey, isA<int>()), |
| containsPair(AnalysisMetrics.didInitializeAnalysisServerKey, true), |
| ), |
| ); |
| |
| await testHarness.callToolWithRetry( |
| CallToolRequest( |
| name: DartAnalyzerSupport.lspTool.name, |
| arguments: { |
| ParameterNames.command: LspCommands.hover, |
| ParameterNames.uri: p.join(exampleRoot.uri, 'main.dart'), |
| ParameterNames.line: 0, |
| ParameterNames.column: 0, |
| }, |
| ), |
| ); |
| expect( |
| analytics.sentEvents.last.eventData, |
| allOf( |
| isNot(contains(AnalysisMetrics.applyFixesTimeMsKey)), |
| containsPair(AnalysisMetrics.analyzerReadyTimeMsKey, isA<int>()), |
| containsPair(AnalysisMetrics.didInitializeAnalysisServerKey, false), |
| ), |
| ); |
| }); |
| }); |
| } |