diff --git a/.github/workflows/dart_tooling_mcp_server.yaml b/.github/workflows/dart_mcp_server.yaml similarity index 100% rename from .github/workflows/dart_tooling_mcp_server.yaml rename to .github/workflows/dart_mcp_server.yaml
diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml index 54e3df5..1333e86 100644 --- a/.github/workflows/pull_request_label.yml +++ b/.github/workflows/pull_request_label.yml
@@ -13,6 +13,7 @@ jobs: label: permissions: + issues: write pull-requests: write runs-on: ubuntu-latest steps:
diff --git a/pkgs/dart_mcp/CHANGELOG.md b/pkgs/dart_mcp/CHANGELOG.md index 7ad45d7..2e5da88 100644 --- a/pkgs/dart_mcp/CHANGELOG.md +++ b/pkgs/dart_mcp/CHANGELOG.md
@@ -1,4 +1,4 @@ -## 0.2.1-wip +## 0.2.1 - Fix the `protocolLogSink` support when using `MCPClient.connectStdioServer`. - Update workflow example to show thinking spinner and input and output token
diff --git a/pkgs/dart_mcp/pubspec.yaml b/pkgs/dart_mcp/pubspec.yaml index 44dd545..2c5df2d 100644 --- a/pkgs/dart_mcp/pubspec.yaml +++ b/pkgs/dart_mcp/pubspec.yaml
@@ -1,5 +1,5 @@ name: dart_mcp -version: 0.2.1-wip +version: 0.2.1 description: A package for making MCP servers and clients. repository: https://github.com/dart-lang/ai/tree/main/pkgs/dart_mcp issue_tracker: https://github.com/dart-lang/ai/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Adart_mcp
diff --git a/pkgs/dart_mcp_server/README.md b/pkgs/dart_mcp_server/README.md index b4ae9d0..12e4adc 100644 --- a/pkgs/dart_mcp_server/README.md +++ b/pkgs/dart_mcp_server/README.md
@@ -23,6 +23,8 @@ | `hot_reload` | `runtime tool` | Performs a hot reload of the active Flutter application. | | `connect_dart_tooling_daemon`* | `configuration` | Connects to the locally running Dart Tooling Daemon. | | `get_active_location` | `editor` | Gets the active cursor position in the connected editor (if available). | +| `run_tests` | `static tool` | Runs tests for the given project roots. | +| `create_project` | `static tool` | Creates a new Dart or Flutter project. | > *Experimental: may be removed.
diff --git a/pkgs/dart_mcp_server/bin/main.dart b/pkgs/dart_mcp_server/bin/main.dart index 6050251..351dc72 100644 --- a/pkgs/dart_mcp_server/bin/main.dart +++ b/pkgs/dart_mcp_server/bin/main.dart
@@ -20,9 +20,14 @@ } DartMCPServer? server; - await runZonedGuarded( - () async { - server = await DartMCPServer.connect( + final dartSdkPath = + parsedArgs.option(dartSdkOption) ?? io.Platform.environment['DART_SDK']; + final flutterSdkPath = + parsedArgs.option(flutterSdkOption) ?? + io.Platform.environment['FLUTTER_SDK']; + runZonedGuarded( + () { + server = DartMCPServer( StreamChannel.withCloseGuarantee(io.stdin, io.stdout) .transform(StreamChannelTransformer.fromCodec(utf8)) .transformStream(const LineSplitter()) @@ -34,6 +39,7 @@ ), ), forceRootsFallback: parsedArgs.flag(forceRootsFallback), + sdk: Sdk.find(dartSdkPath: dartSdkPath, flutterSdkPath: flutterSdkPath), ); }, (e, s) { @@ -65,6 +71,19 @@ final argParser = ArgParser(allowTrailingOptions: false) + ..addOption( + dartSdkOption, + help: + 'The path to the root of the desired Dart SDK. Defaults to the ' + 'DART_SDK environment variable.', + ) + ..addOption( + flutterSdkOption, + help: + 'The path to the root of the desired Flutter SDK. Defaults to ' + 'the FLUTTER_SDK environment variable, then searching up from the ' + 'Dart SDK.', + ) ..addFlag( forceRootsFallback, negatable: true, @@ -77,5 +96,7 @@ ) ..addFlag(help, abbr: 'h', help: 'Show usage text'); +const dartSdkOption = 'dart-sdk'; +const flutterSdkOption = 'flutter-sdk'; const forceRootsFallback = 'force-roots-fallback'; const help = 'help';
diff --git a/pkgs/dart_mcp_server/lib/dart_mcp_server.dart b/pkgs/dart_mcp_server/lib/dart_mcp_server.dart index c1e1ead..badafe8 100644 --- a/pkgs/dart_mcp_server/lib/dart_mcp_server.dart +++ b/pkgs/dart_mcp_server/lib/dart_mcp_server.dart
@@ -3,3 +3,4 @@ // BSD-style license that can be found in the LICENSE file. export 'src/server.dart'; +export 'src/utils/sdk.dart' show Sdk;
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart b/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart index da870e7..10ae558 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart
@@ -14,13 +14,15 @@ 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 { + on ToolsSupport, LoggingSupport, RootsTrackingSupport + implements SdkSupport { /// The LSP server connection for the analysis server. late final Peer _lspConnection; @@ -52,9 +54,8 @@ if (!supportsRoots) 'Project analysis requires the "roots" capability which is not ' 'supported. Analysis tools have been disabled.', - if (Platform.environment['DART_SDK'] == null) - 'Project analysis requires a "DART_SDK" environment variable to be set ' - '(this should be the path to the root of the dart SDK). Analysis ' + if (sdk.dartSdkPath == null) + 'Project analysis requires a Dart SDK but none was given. Analysis ' 'tools have been disabled.', ]; @@ -90,7 +91,7 @@ /// /// On failure, returns a reason for the failure. Future<String?> _initializeAnalyzerLspServer() async { - _lspServer = await Process.start('dart', [ + _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
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart b/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart index 7784dee..be32e73 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
@@ -5,11 +5,13 @@ import 'dart:async'; import 'package:dart_mcp/server.dart'; +import 'package:path/path.dart' as p; import '../utils/cli_utils.dart'; import '../utils/constants.dart'; import '../utils/file_system.dart'; import '../utils/process_manager.dart'; +import '../utils/sdk.dart'; /// Mix this in to any MCPServer to add support for running Dart or Flutter CLI /// commands like `dart fix`, `dart format`, and `flutter test`. @@ -17,17 +19,18 @@ /// The MCPServer must already have the [ToolsSupport] and [LoggingSupport] /// mixins applied. base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport - implements ProcessManagerSupport, FileSystemSupport { + implements ProcessManagerSupport, FileSystemSupport, SdkSupport { @override FutureOr<InitializeResult> initialize(InitializeRequest request) { try { return super.initialize(request); } finally { - // Can't call this until after `super.initialize`. - if (supportsRoots) { + // Can't call `supportsRoots` until after `super.initialize`. + if (supportsRoots && sdk.dartSdkPath != null) { registerTool(dartFixTool, _runDartFixTool); registerTool(dartFormatTool, _runDartFormatTool); registerTool(runTestsTool, _runTests); + registerTool(createProjectTool, _runCreateProjectTool); } } } @@ -36,12 +39,13 @@ Future<CallToolResult> _runDartFixTool(CallToolRequest request) async { return runCommandInRoots( request, - commandForRoot: (_, _) => 'dart', + commandForRoot: (_, _, sdk) => sdk.dartExecutablePath, arguments: ['fix', '--apply'], commandDescription: 'dart fix', processManager: processManager, knownRoots: await roots, fileSystem: fileSystem, + sdk: sdk, ); } @@ -49,13 +53,14 @@ Future<CallToolResult> _runDartFormatTool(CallToolRequest request) async { return runCommandInRoots( request, - commandForRoot: (_, _) => 'dart', + commandForRoot: (_, _, sdk) => sdk.dartExecutablePath, arguments: ['format'], commandDescription: 'dart format', processManager: processManager, defaultPaths: ['.'], knownRoots: await roots, fileSystem: fileSystem, + sdk: sdk, ); } @@ -68,6 +73,99 @@ processManager: processManager, knownRoots: await roots, fileSystem: fileSystem, + sdk: sdk, + ); + } + + /// Implementation of the [createProjectTool]. + Future<CallToolResult> _runCreateProjectTool(CallToolRequest request) async { + final args = request.arguments; + + final errors = createProjectTool.inputSchema.validate(args); + final projectType = args?[ParameterNames.projectType] as String?; + if (projectType != 'dart' && projectType != 'flutter') { + errors.add( + ValidationError( + ValidationErrorType.itemInvalid, + path: [ParameterNames.projectType], + details: 'Only `dart` and `flutter` are allowed values.', + ), + ); + } + final directory = args![ParameterNames.directory] as String; + if (p.isAbsolute(directory)) { + errors.add( + ValidationError( + ValidationErrorType.itemInvalid, + path: [ParameterNames.directory], + details: 'Directory must be a relative path.', + ), + ); + } + final platforms = + ((args[ParameterNames.platform] as List?)?.cast<String>() ?? []) + .toSet(); + if (projectType == 'flutter') { + // Platforms are ignored for Dart, so no need to validate them. + final invalidPlatforms = platforms.difference(_allowedFlutterPlatforms); + if (invalidPlatforms.isNotEmpty) { + final plural = + invalidPlatforms.length > 1 + ? 'are not valid platforms' + : 'is not a valid platform'; + errors.add( + ValidationError( + ValidationErrorType.itemInvalid, + path: [ParameterNames.platform], + details: + '${invalidPlatforms.join(',')} $plural. Platforms ' + '${_allowedFlutterPlatforms.map((e) => '`$e`').join(', ')} ' + 'are the only allowed values for the platform list argument.', + ), + ); + } + } + + if (errors.isNotEmpty) { + return CallToolResult( + content: [ + for (final error in errors) Content.text(text: error.toErrorString()), + ], + isError: true, + ); + } + + final template = args[ParameterNames.template] as String?; + + final commandArgs = [ + 'create', + if (template != null && template.isNotEmpty) ...['--template', template], + if (projectType == 'flutter' && platforms.isNotEmpty) + '--platform=${platforms.join(',')}', + // Create an "empty" project by default so the LLM doesn't have to deal + // with all the boilerplate and comments. + if (projectType == 'flutter' && + (args[ParameterNames.empty] as bool? ?? true)) + '--empty', + directory, + ]; + + return runCommandInRoot( + request, + arguments: commandArgs, + commandForRoot: + (_, _, sdk) => + switch (projectType) { + 'dart' => sdk.dartExecutablePath, + 'flutter' => sdk.flutterExecutablePath, + _ => StateError('Unknown project type: $projectType'), + } + as String, + commandDescription: '$projectType create', + fileSystem: fileSystem, + processManager: processManager, + knownRoots: await roots, + sdk: sdk, ); } @@ -97,4 +195,53 @@ properties: {ParameterNames.roots: rootsSchema(supportsPaths: true)}, ), ); + + static final createProjectTool = Tool( + name: 'create_project', + description: 'Creates a new Dart or Flutter project.', + annotations: ToolAnnotations( + title: 'Create project', + destructiveHint: true, + ), + inputSchema: Schema.object( + properties: { + ParameterNames.root: rootSchema, + ParameterNames.directory: Schema.string( + description: + 'The subdirectory in which to create the project, must ' + 'be a relative path.', + ), + ParameterNames.projectType: Schema.string( + description: "The type of project: 'dart' or 'flutter'.", + ), + ParameterNames.template: Schema.string( + description: + 'The project template to use (e.g., "console-full", "app").', + ), + ParameterNames.platform: Schema.list( + items: Schema.string(), + description: + 'The list of platforms this project supports. Only valid ' + 'for Flutter projects. The allowed values are ' + '${_allowedFlutterPlatforms.map((e) => '`$e`').join(', ')}. ' + 'Defaults to creating a project for all platforms.', + ), + ParameterNames.empty: Schema.bool( + description: + 'Whether or not to create an "empty" project with minimized ' + 'boilerplate and example code. Defaults to true.', + ), + }, + required: [ParameterNames.directory, ParameterNames.projectType], + ), + ); + + static const _allowedFlutterPlatforms = { + 'web', + 'linux', + 'macos', + 'windows', + 'android', + 'ios', + }; }
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart index efd9524..1dfffe4 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
@@ -109,12 +109,14 @@ final resource = Resource( uri: '$runtimeErrorsScheme://${debugSession.id}', name: debugSession.name, + description: + 'Recent runtime errors seen for debug session ' + '"${debugSession.name}".', ); addResource(resource, (request) async { - final errors = errorService.errors; return ReadResourceResult( contents: [ - for (var error in errors) + for (var error in errorService.errorLog.errors) TextResourceContents(uri: resource.uri, text: error), ], ); @@ -294,7 +296,7 @@ (await _AppErrorsListener.forVmService( vmService, this, - )).errors.clear(); + )).errorLog.clear(); } final vm = await vmService.getVM(); @@ -372,9 +374,9 @@ vmService, this, ); - final errors = errorService.errors; + final errorLog = errorService.errorLog; - if (errors.isEmpty) { + if (errorLog.errors.isEmpty) { return CallToolResult( content: [TextContent(text: 'No runtime errors found.')], ); @@ -383,14 +385,14 @@ content: [ TextContent( text: - 'Found ${errors.length} ' - 'error${errors.length == 1 ? '' : 's'}:\n', + 'Found ${errorLog.errors.length} ' + 'error${errorLog.errors.length == 1 ? '' : 's'}:\n', ), - ...errors.map((e) => TextContent(text: e.toString())), + for (final e in errorLog.errors) TextContent(text: e.toString()), ], ); if (request.arguments?['clearRuntimeErrors'] == true) { - errorService.errors.clear(); + errorService.errorLog.clear(); } return result; } catch (e) { @@ -614,9 +616,9 @@ static final getRuntimeErrorsTool = Tool( name: 'get_runtime_errors', description: - 'Retrieves the list of runtime errors that have occurred in the active ' - 'Dart or Flutter application. Requires "${connectTool.name}" to be ' - 'successfully called first.', + 'Retrieves the most recent runtime errors that have occurred in the ' + 'active Dart or Flutter application. Requires "${connectTool.name}" to ' + 'be successfully called first.', annotations: ToolAnnotations( title: 'Get runtime errors', readOnlyHint: true, @@ -767,7 +769,7 @@ /// Listens on a VM service for errors. class _AppErrorsListener { /// All the errors recorded so far (may be cleared explicitly). - final List<String> errors; + final ErrorLog errorLog; /// A broadcast stream of all errors that come in after you start listening. Stream<String> get errorsStream => _errorsController.stream; @@ -785,7 +787,7 @@ final VmService _vmService; _AppErrorsListener._( - this.errors, + this.errorLog, this._errorsController, this._extensionEventsListener, this._stderrEventsListener, @@ -809,8 +811,8 @@ // list but also expose it to clients so they can know when new errors // are added. final errorsController = StreamController<String>.broadcast(); - final errors = <String>[]; - errorsController.stream.listen(errors.add); + final errorLog = ErrorLog(); + errorsController.stream.listen(errorLog.add); // We need to listen to streams with history so that we can get errors // that occurred before this tool call. // TODO(https://github.com/dart-lang/ai/issues/57): this can result in @@ -849,7 +851,7 @@ logger.log(LoggingLevel.error, 'Error subscribing to app errors: $e'); } return _AppErrorsListener._( - errors, + errorLog, errorsController, extensionEvents, stderrEvents, @@ -859,7 +861,7 @@ } Future<void> shutdown() async { - errors.clear(); + errorLog.clear(); await _errorsController.close(); await _extensionEventsListener.cancel(); await _stderrEventsListener.cancel(); @@ -958,3 +960,52 @@ if (vmServiceUri != null) 'vmServiceUri': vmServiceUri, }); } + +/// Manages a log of errors with a maximum size in terms of total characters. +@visibleForTesting +class ErrorLog { + Iterable<String> get errors => _errors; + final List<String> _errors = []; + int _characters = 0; + + /// The number of characters used by all errors in the log. + @visibleForTesting + int get characters => _characters; + + final int _maxSize; + + ErrorLog({ + // One token is ~4 characters. Allow up to 5k tokens by default, so 20k + // characters. + int maxSize = 20000, + }) : _maxSize = maxSize; + + /// Adds a new [error] to the log. + void add(String error) { + if (error.length > _maxSize) { + // If we get a single error over the max size, just trim it and clear + // all other errors. + final trimmed = error.substring(0, _maxSize); + _errors.clear(); + _characters = trimmed.length; + _errors.add(trimmed); + } else { + // Otherwise, we append the error and then remove as many errors from the + // front as we need to in order to get under the max size. + _characters += error.length; + _errors.add(error); + var removeCount = 0; + while (_characters > _maxSize) { + _characters -= _errors[removeCount].length; + removeCount++; + } + _errors.removeRange(0, removeCount); + } + } + + /// Clears all errors. + void clear() { + _characters = 0; + _errors.clear(); + } +}
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/pub.dart b/pkgs/dart_mcp_server/lib/src/mixins/pub.dart index eaca4bc..ab48500 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/pub.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/pub.dart
@@ -10,6 +10,7 @@ import '../utils/constants.dart'; import '../utils/file_system.dart'; import '../utils/process_manager.dart'; +import '../utils/sdk.dart'; /// Mix this in to any MCPServer to add support for running Pub commands like /// like `pub add` and `pub get`. @@ -19,7 +20,7 @@ /// The MCPServer must already have the [ToolsSupport] and [LoggingSupport] /// mixins applied. base mixin PubSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport - implements ProcessManagerSupport, FileSystemSupport { + implements ProcessManagerSupport, FileSystemSupport, SdkSupport { @override FutureOr<InitializeResult> initialize(InitializeRequest request) { try { @@ -79,6 +80,7 @@ processManager: processManager, knownRoots: await roots, fileSystem: fileSystem, + sdk: sdk, ); }
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart b/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart index fabed58..02b6ecd 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/pub_dev_search.dart
@@ -14,14 +14,11 @@ /// Limit the number of concurrent requests. final _pool = Pool(10); -/// The number of reults to return for a query. +/// The number of results to return for a query. // If this should be set higher than 10 we need to implement paging of the // http://pub.dev/api/search endpoint. final _resultsLimit = 10; -/// The number of identifiers we list per packages. -final _maxIdentifiersListed = 200; - /// Mix this in to any MCPServer to add support for doing searches on pub.dev. base mixin PubDevSupport on ToolsSupport { final _client = Client(); @@ -56,7 +53,7 @@ return CallToolResult( content: [ TextContent( - text: 'No packages mached the query, consider simplifying it.', + text: 'No packages matched the query, consider simplifying it.', ), ], isError: true, @@ -80,9 +77,6 @@ (packageName) => ( versionListing: retrieve('api/packages/$packageName'), score: retrieve('api/packages/$packageName/score'), - docIndex: retrieve( - 'documentation/$packageName/latest/index.json', - ), ), ) .toList(); @@ -94,18 +88,6 @@ final packageName = packageNames[i]; final versionListing = await subQueryFutures[i].versionListing; final scoreResult = await subQueryFutures[i].score; - final docIndex = await subQueryFutures[i].docIndex; - - Map<String, Object?> identifiers(Object index) { - final items = dig<List>(index, []); - return { - 'qualifiedNames': [ - for (final item in items.take(_maxIdentifiersListed)) - dig<String>(item, ['qualifiedName']), - ], - }; - } - results.add( TextContent( text: jsonEncode({ @@ -144,7 +126,6 @@ .where((t) => (t as String).startsWith('publisher:')) .firstOrNull, }, - if (docIndex != null) ...{'api': identifiers(docIndex)}, }), ), ); @@ -164,8 +145,7 @@ description: 'Searches pub.dev for packages relevant to a given search query. ' 'The response will describe each result with its download count, ' - 'package description, topics, license, publisher, and a list of ' - 'identifiers in the public api.', + 'package description, topics, license, and publisher.', annotations: ToolAnnotations(title: 'pub.dev search', readOnlyHint: true), inputSchema: Schema.object( properties: {
diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart index 072052e..d9d573d 100644 --- a/pkgs/dart_mcp_server/lib/src/server.dart +++ b/pkgs/dart_mcp_server/lib/src/server.dart
@@ -2,14 +2,11 @@ // 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 'package:dart_mcp/server.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; -import 'package:stream_channel/stream_channel.dart'; import 'mixins/analyzer.dart'; import 'mixins/dash_cli.dart'; @@ -19,6 +16,7 @@ import 'mixins/roots_fallback_support.dart'; import 'utils/file_system.dart'; import 'utils/process_manager.dart'; +import 'utils/sdk.dart'; /// An MCP server for Dart and Flutter tooling. final class DartMCPServer extends MCPServer @@ -33,9 +31,10 @@ PubSupport, PubDevSupport, DartToolingDaemonSupport - implements ProcessManagerSupport, FileSystemSupport { + implements ProcessManagerSupport, FileSystemSupport, SdkSupport { DartMCPServer( super.channel, { + required this.sdk, @visibleForTesting this.processManager = const LocalProcessManager(), @visibleForTesting this.fileSystem = const LocalFileSystem(), this.forceRootsFallback = false, @@ -49,13 +48,6 @@ 'their development tools and running applications.', ); - static Future<DartMCPServer> connect( - StreamChannel<String> mcpChannel, { - bool forceRootsFallback = false, - }) async { - return DartMCPServer(mcpChannel, forceRootsFallback: forceRootsFallback); - } - @override final LocalProcessManager processManager; @@ -64,4 +56,7 @@ @override final bool forceRootsFallback; + + @override + final Sdk sdk; }
diff --git a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart index 1ecf8b3..44e2389 100644 --- a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart +++ b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
@@ -11,6 +11,7 @@ import 'package:yaml/yaml.dart'; import 'constants.dart'; +import 'sdk.dart'; /// The supported kinds of projects. enum ProjectKind { @@ -71,7 +72,7 @@ /// root's 'paths'. Future<CallToolResult> runCommandInRoots( CallToolRequest request, { - FutureOr<String> Function(Root, FileSystem) commandForRoot = + FutureOr<String> Function(Root, FileSystem, Sdk) commandForRoot = defaultCommandForRoot, List<String> arguments = const [], required String commandDescription, @@ -79,6 +80,7 @@ required ProcessManager processManager, required List<Root> knownRoots, List<String> defaultPaths = const <String>[], + required Sdk sdk, }) async { var rootConfigs = (request.arguments?[ParameterNames.roots] as List?) @@ -91,120 +93,172 @@ ]; } - final outputs = <TextContent>[]; + final outputs = <Content>[]; for (var rootConfig in rootConfigs) { - final rootUriString = rootConfig[ParameterNames.root] as String?; - if (rootUriString == null) { - // This shouldn't happen based on the schema, but handle defensively. - return CallToolResult( - content: [ - TextContent(text: 'Invalid root configuration: missing `root` key.'), - ], - isError: true, - ); - } - - final root = _findRoot(rootUriString, knownRoots); - if (root == null) { - return CallToolResult( - content: [ - TextContent( - text: - 'Invalid root $rootUriString, must be under one of the ' - 'registered project roots:\n\n${knownRoots.join('\n')}', - ), - ], - isError: true, - ); - } - - final rootUri = Uri.parse(rootUriString); - if (rootUri.scheme != 'file') { - return CallToolResult( - content: [ - TextContent( - text: - 'Only file scheme uris are allowed for roots, but got ' - '$rootUri', - ), - ], - isError: true, - ); - } - final projectRoot = fileSystem.directory(rootUri); - - final commandWithPaths = <String>[ - await commandForRoot(root, fileSystem), - ...arguments, - ]; - final paths = - (rootConfig[ParameterNames.paths] as List?)?.cast<String>() ?? - defaultPaths; - final invalidPaths = paths.where((path) { - final resolvedPath = rootUri.resolve(path).toString(); - return rootUriString != resolvedPath && - !p.isWithin(rootUriString, resolvedPath); - }); - if (invalidPaths.isNotEmpty) { - return CallToolResult( - content: [ - TextContent( - text: - 'Paths are not allowed to escape their project root:\n' - '${invalidPaths.join('\n')}', - ), - ], - isError: true, - ); - } - commandWithPaths.addAll(paths); - - final result = await processManager.run( - commandWithPaths, - workingDirectory: projectRoot.path, - runInShell: true, + final result = await runCommandInRoot( + request, + rootConfig: rootConfig, + commandForRoot: commandForRoot, + arguments: arguments, + commandDescription: commandDescription, + fileSystem: fileSystem, + processManager: processManager, + knownRoots: knownRoots, + defaultPaths: defaultPaths, + sdk: sdk, ); - - final output = (result.stdout as String).trim(); - final errors = (result.stderr as String).trim(); - if (result.exitCode != 0) { - return CallToolResult( - content: [ - TextContent( - text: - '$commandDescription failed in ${projectRoot.path}:\n' - '$output\n\nErrors\n$errors', - ), - ], - isError: true, - ); - } - if (output.isNotEmpty) { - outputs.add( - TextContent( - text: '$commandDescription in ${projectRoot.path}:\n$output', - ), - ); - } + if (result.isError == true) return result; + outputs.addAll(result.content); } return CallToolResult(content: outputs); } +/// Runs [commandForRoot] in a single project root specified in the +/// [request], with [arguments]. +/// +/// If [rootConfig] is passed, this will be used to read the root configuration, +/// otherwise it is read directly off of `request.arguments`. +/// +/// These [commandForRoot] plus [arguments] are passed directly to +/// [ProcessManager.run]. +/// +/// The [commandDescription] is used in the output to describe the command +/// being run. For example, if the command is `['dart', 'fix', '--apply']`, the +/// command description might be `dart fix`. +/// +/// [defaultPaths] may be specified if one or more path arguments are required +/// for the command (e.g. `dart format <default paths>`). The paths can be +/// absolute or relative paths that point to the directories on which the +/// command should be run. For example, the `dart format` command may pass a +/// default path of '.', which indicates that every Dart file in the working +/// directory should be formatted. The value of `defaultPaths` will only be used +/// if the [request]'s root configuration does not contain a set value for a +/// root's 'paths'. +Future<CallToolResult> runCommandInRoot( + CallToolRequest request, { + Map<String, Object?>? rootConfig, + FutureOr<String> Function(Root, FileSystem, Sdk) commandForRoot = + defaultCommandForRoot, + List<String> arguments = const [], + required String commandDescription, + required FileSystem fileSystem, + required ProcessManager processManager, + required List<Root> knownRoots, + List<String> defaultPaths = const <String>[], + required Sdk sdk, +}) async { + rootConfig ??= request.arguments; + final rootUriString = rootConfig?[ParameterNames.root] as String?; + if (rootUriString == null) { + // This shouldn't happen based on the schema, but handle defensively. + return CallToolResult( + content: [ + TextContent(text: 'Invalid root configuration: missing `root` key.'), + ], + isError: true, + ); + } + + final root = _findRoot(rootUriString, knownRoots); + if (root == null) { + return CallToolResult( + content: [ + TextContent( + text: + 'Invalid root $rootUriString, must be under one of the ' + 'registered project roots:\n\n${knownRoots.join('\n')}', + ), + ], + isError: true, + ); + } + + final rootUri = Uri.parse(rootUriString); + if (rootUri.scheme != 'file') { + return CallToolResult( + content: [ + TextContent( + text: + 'Only file scheme uris are allowed for roots, but got ' + '$rootUri', + ), + ], + isError: true, + ); + } + final projectRoot = fileSystem.directory(rootUri); + + final commandWithPaths = <String>[ + await commandForRoot(root, fileSystem, sdk), + ...arguments, + ]; + final paths = + (rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ?? + defaultPaths; + final invalidPaths = paths.where((path) { + final resolvedPath = rootUri.resolve(path).toString(); + return rootUriString != resolvedPath && + !p.isWithin(rootUriString, resolvedPath); + }); + if (invalidPaths.isNotEmpty) { + return CallToolResult( + content: [ + TextContent( + text: + 'Paths are not allowed to escape their project root:\n' + '${invalidPaths.join('\n')}', + ), + ], + isError: true, + ); + } + commandWithPaths.addAll(paths); + + final result = await processManager.run( + commandWithPaths, + workingDirectory: projectRoot.path, + runInShell: true, + ); + + final output = (result.stdout as String).trim(); + final errors = (result.stderr as String).trim(); + if (result.exitCode != 0) { + return CallToolResult( + content: [ + TextContent( + text: + '$commandDescription failed in ${projectRoot.path}:\n' + '$output\n\nErrors\n$errors', + ), + ], + isError: true, + ); + } + return CallToolResult( + content: [ + TextContent(text: '$commandDescription in ${projectRoot.path}:\n$output'), + ], + ); +} + /// Returns 'dart' or 'flutter' based on the pubspec contents. /// /// Throws an [ArgumentError] if there is no pubspec. -Future<String> defaultCommandForRoot(Root root, FileSystem fileSystem) async => - switch (await inferProjectKind(root, fileSystem)) { - ProjectKind.dart => 'dart', - ProjectKind.flutter => 'flutter', - ProjectKind.unknown => - throw ArgumentError.value( - root.uri, - 'root.uri', - 'Unknown project kind at root ${root.uri}. All projects must have a ' - 'pubspec.', - ), - }; +Future<String> defaultCommandForRoot( + Root root, + FileSystem fileSystem, + Sdk sdk, +) async => switch (await inferProjectKind(root, fileSystem)) { + ProjectKind.dart => sdk.dartExecutablePath, + ProjectKind.flutter => sdk.flutterExecutablePath, + ProjectKind.unknown => + throw ArgumentError.value( + root.uri, + 'root.uri', + 'Unknown project kind at root ${root.uri}. All projects must have a ' + 'pubspec.', + ), +}; /// Returns whether or not [rootUri] is an allowed root, either exactly matching /// or under on of the [knownRoots]. @@ -224,12 +278,7 @@ title: 'All projects roots to run this tool in.', items: Schema.object( properties: { - ParameterNames.root: Schema.string( - title: 'The URI of the project root to run this tool in.', - description: - 'This must be equal to or a subdirectory of one of the roots ' - 'returned by a call to "listRoots".', - ), + ParameterNames.root: rootSchema, if (supportsPaths) ParameterNames.paths: Schema.list( title: @@ -242,6 +291,14 @@ ), ); +final rootSchema = Schema.string( + title: 'The file URI of the project root to run this tool in.', + description: + 'This must be equal to or a subdirectory of one of the roots ' + 'allowed by the client. Must be a URI with a `file:` ' + 'scheme (e.g. file:///absolute/path/to/root).', +); + /// Very thin extension type for a pubspec just containing what we need. /// /// We assume a valid pubspec.
diff --git a/pkgs/dart_mcp_server/lib/src/utils/constants.dart b/pkgs/dart_mcp_server/lib/src/utils/constants.dart index 78d3edf..a5a2729 100644 --- a/pkgs/dart_mcp_server/lib/src/utils/constants.dart +++ b/pkgs/dart_mcp_server/lib/src/utils/constants.dart
@@ -8,14 +8,19 @@ extension ParameterNames on Never { static const column = 'column'; static const command = 'command'; + static const directory = 'directory'; + static const empty = 'empty'; static const line = 'line'; static const name = 'name'; static const packageName = 'packageName'; static const paths = 'paths'; + static const platform = 'platform'; static const position = 'position'; + static const projectType = 'projectType'; static const query = 'query'; static const root = 'root'; static const roots = 'roots'; + static const template = 'template'; static const uri = 'uri'; static const uris = 'uris'; }
diff --git a/pkgs/dart_mcp_server/lib/src/utils/process_manager.dart b/pkgs/dart_mcp_server/lib/src/utils/process_manager.dart index af6786c..e8c52c1 100644 --- a/pkgs/dart_mcp_server/lib/src/utils/process_manager.dart +++ b/pkgs/dart_mcp_server/lib/src/utils/process_manager.dart
@@ -16,5 +16,5 @@ /// implement this class and use [processManager] instead of making direct calls /// to dart:io's [Process] class. abstract interface class ProcessManagerSupport { - LocalProcessManager get processManager; + ProcessManager get processManager; }
diff --git a/pkgs/dart_mcp_server/lib/src/utils/sdk.dart b/pkgs/dart_mcp_server/lib/src/utils/sdk.dart new file mode 100644 index 0000000..f3f5523 --- /dev/null +++ b/pkgs/dart_mcp_server/lib/src/utils/sdk.dart
@@ -0,0 +1,86 @@ +// 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:io'; + +import 'package:path/path.dart' as p; + +/// An interface class that provides a single getter of type [Sdk]. +/// +/// This provides information about the Dart and Flutter sdks, if available. +abstract interface class SdkSupport { + Sdk get sdk; +} + +/// Information about the Dart and Flutter SDKs, if available. +class Sdk { + /// The path to the root of the Dart SDK. + final String? dartSdkPath; + + /// The path to the root of the Flutter SDK. + final String? flutterSdkPath; + + Sdk({this.dartSdkPath, this.flutterSdkPath}); + + /// Creates an [Sdk] from the path to the Dart SDK. + /// + /// If no [dartSdkPath] is given, this will attempt to find one using + /// [Platform.resolvedExecutable], assuming that is the `dart` binary + /// under the `bin` dir of a Dart SDK. + /// + /// Validates that the path is valid by checking for the `version` file. + /// + /// If no [flutterSdkPath] is given, this will search up from the resolved + /// Dart SDK path to see if it is nested inside a Flutter SDK. + factory Sdk.find({String? dartSdkPath, String? flutterSdkPath}) { + // Assume that we are running from the Dart SDK bin dir if not given any + // other configuration. + dartSdkPath ??= p.dirname(p.dirname(Platform.resolvedExecutable)); + + final versionFile = dartSdkPath.child('version'); + if (!File(versionFile).existsSync()) { + throw ArgumentError('Invalid Dart SDK path: $dartSdkPath'); + } + + // Check if this is nested inside a Flutter SDK. + if (dartSdkPath.parent case final cacheDir + when cacheDir.basename == 'cache' && flutterSdkPath == null) { + if (cacheDir.parent case final binDir when binDir.basename == 'bin') { + final flutterExecutable = binDir.child('flutter'); + if (File(flutterExecutable).existsSync()) { + flutterSdkPath = binDir.parent; + } + } + } + + return Sdk(dartSdkPath: dartSdkPath, flutterSdkPath: flutterSdkPath); + } + + /// The path to the `dart` executable. + /// + /// Throws an [ArgumentError] if [dartSdkPath] is `null`. + String get dartExecutablePath => + dartSdkPath?.child('bin').child('dart') ?? + (throw ArgumentError( + 'Dart SDK location unknown, try setting the DART_SDK environment ' + 'variable.', + )); + + /// The path to the `flutter` executable. + /// + /// Throws an [ArgumentError] if [flutterSdkPath] is `null`. + String get flutterExecutablePath => + flutterSdkPath?.child('bin').child('flutter') ?? + (throw ArgumentError( + 'Flutter SDK location unknown. To work on flutter projects, you must ' + 'spawn the server using `dart` from the flutter SDK and not a Dart ' + 'SDK, or set a FLUTTER_SDK environment variable.', + )); +} + +extension on String { + String get basename => p.basename(this); + String child(String path) => p.join(this, path); + String get parent => p.dirname(this); +}
diff --git a/pkgs/dart_mcp_server/pubspec.yaml b/pkgs/dart_mcp_server/pubspec.yaml index 3eacc6a..5b257e4 100644 --- a/pkgs/dart_mcp_server/pubspec.yaml +++ b/pkgs/dart_mcp_server/pubspec.yaml
@@ -15,7 +15,7 @@ dependencies: args: ^2.7.0 async: ^2.13.0 - dart_mcp: ^0.2.0 + dart_mcp: ^0.2.1 dds_service_extensions: ^2.0.1 devtools_shared: ^11.2.0 dtd: ^2.4.0
diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart index 1a9bdae..a0c0f1c 100644 --- a/pkgs/dart_mcp_server/test/test_harness.dart +++ b/pkgs/dart_mcp_server/test/test_harness.dart
@@ -11,6 +11,7 @@ import 'package:dart_mcp_server/src/mixins/dtd.dart'; import 'package:dart_mcp_server/src/server.dart'; import 'package:dart_mcp_server/src/utils/constants.dart'; +import 'package:dart_mcp_server/src/utils/sdk.dart'; import 'package:dtd/dtd.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; @@ -34,6 +35,7 @@ final DartToolingMCPClient mcpClient; final ServerConnectionPair serverConnectionPair; final FileSystem fileSystem; + final Sdk sdk; ServerConnection get mcpServerConnection => serverConnectionPair.serverConnection; @@ -43,6 +45,7 @@ this.serverConnectionPair, this.fakeEditorExtension, this.fileSystem, + this.sdk, ); /// Starts a Dart Tooling Daemon as well as an MCP client and server, and @@ -64,6 +67,10 @@ bool inProcess = false, FileSystem? fileSystem, }) async { + final sdk = Sdk.find( + dartSdkPath: Platform.environment['DART_SDK'], + flutterSdkPath: Platform.environment['FLUTTER_SDK'], + ); fileSystem ??= const LocalFileSystem(); final mcpClient = DartToolingMCPClient(); @@ -73,13 +80,14 @@ mcpClient, inProcess, fileSystem, + sdk, ); final connection = serverConnectionPair.serverConnection; connection.onLog.listen((log) { printOnFailure('MCP Server Log: $log'); }); - final fakeEditorExtension = await FakeEditorExtension.connect(); + final fakeEditorExtension = await FakeEditorExtension.connect(sdk); addTearDown(fakeEditorExtension.shutdown); return TestHarness._( @@ -87,6 +95,7 @@ serverConnectionPair, fakeEditorExtension, fileSystem, + sdk, ); } @@ -102,6 +111,7 @@ appPath, isFlutter: isFlutter, args: args, + sdk: sdk, ); await fakeEditorExtension.addDebugSession(session); final root = rootForPath(projectRoot); @@ -192,15 +202,20 @@ String appPath, { List<String> args = const [], required bool isFlutter, + required Sdk sdk, }) async { - final process = await TestProcess.start(isFlutter ? 'flutter' : 'dart', [ - 'run', - '--no${isFlutter ? '' : '-serve'}-devtools', - if (!isFlutter) '--enable-vm-service=0', - if (isFlutter) ...['-d', 'flutter-tester'], - appPath, - ...args, - ], workingDirectory: projectRoot); + final process = await TestProcess.start( + isFlutter ? sdk.flutterExecutablePath : sdk.dartExecutablePath, + [ + 'run', + '--no${isFlutter ? '' : '-serve'}-devtools', + if (!isFlutter) '--enable-vm-service=0', + if (isFlutter) ...['-d', 'flutter-tester'], + appPath, + ...args, + ], + workingDirectory: projectRoot, + ); addTearDown(() async { await kill(process, isFlutter); @@ -284,8 +299,10 @@ static int get nextId => ++_nextId; static int _nextId = 0; - static Future<FakeEditorExtension> connect() async { - final dtdProcess = await TestProcess.start('dart', ['tooling-daemon']); + static Future<FakeEditorExtension> connect(Sdk sdk) async { + final dtdProcess = await TestProcess.start(sdk.dartExecutablePath, [ + 'tooling-daemon', + ]); final dtdUri = await _getDTDUri(dtdProcess); final dtd = await DartToolingDaemon.connect(Uri.parse(dtdUri)); final extension = FakeEditorExtension._(dtd, dtdProcess, dtdUri); @@ -369,6 +386,7 @@ MCPClient client, bool inProcess, FileSystem fileSystem, + Sdk sdk, ) async { ServerConnection connection; DartMCPServer? server; @@ -393,11 +411,12 @@ serverChannel, processManager: TestProcessManager(), fileSystem: fileSystem, + sdk: sdk, ); addTearDown(server.shutdown); connection = client.connectServer(clientChannel); } else { - connection = await client.connectStdioServer('dart', [ + connection = await client.connectStdioServer(sdk.dartExecutablePath, [ 'pub', // Using `pub` gives us incremental compilation 'run', 'bin/main.dart', @@ -470,12 +489,9 @@ if (item.workingDirectory != value.workingDirectory) { return false; } - if (item.command.length != value.command.length) { + if (!equals(value.command).matches(item.command, matchState)) { return false; } - for (var i = 0; i < item.command.length; i++) { - if (item.command[i] != value.command[i]) return false; - } return true; } }
diff --git a/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart b/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart index d63677f..1473741 100644 --- a/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart +++ b/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart
@@ -39,13 +39,13 @@ exampleFlutterAppRoot = testHarness.rootForPath(flutterExample.io.path); dartCliAppRoot = testHarness.rootForPath(dartCliAppsPath); - testHarness.mcpClient.addRoot(exampleFlutterAppRoot); await pumpEventQueue(); }); group('cli tools', () { late Tool dartFixTool; late Tool dartFormatTool; + late Tool createProjectTool; setUp(() async { final tools = (await testHarness.mcpServerConnection.listTools()).tools; @@ -55,9 +55,13 @@ dartFormatTool = tools.singleWhere( (t) => t.name == DashCliSupport.dartFormatTool.name, ); + createProjectTool = tools.singleWhere( + (t) => t.name == DashCliSupport.createProjectTool.name, + ); }); test('dart fix', () async { + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); final request = CallToolRequest( name: dartFixTool.name, arguments: { @@ -72,13 +76,14 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: ['dart', 'fix', '--apply'], + command: [endsWith('dart'), 'fix', '--apply'], workingDirectory: exampleFlutterAppRoot.path, )), ]); }); test('dart format', () async { + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); final request = CallToolRequest( name: dartFormatTool.name, arguments: { @@ -93,13 +98,14 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: ['dart', 'format', '.'], + command: [endsWith('dart'), 'format', '.'], workingDirectory: exampleFlutterAppRoot.path, )), ]); }); test('dart format with paths', () async { + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); final request = CallToolRequest( name: dartFormatTool.name, arguments: { @@ -117,7 +123,7 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: ['dart', 'format', 'foo.dart', 'bar.dart'], + command: [endsWith('dart'), 'format', 'foo.dart', 'bar.dart'], workingDirectory: exampleFlutterAppRoot.path, )), ]); @@ -125,6 +131,7 @@ test('flutter and dart package tests with paths', () async { testHarness.mcpClient.addRoot(dartCliAppRoot); + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); await pumpEventQueue(); final request = CallToolRequest( name: DashCliSupport.runTestsTool.name, @@ -147,14 +154,227 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: ['flutter', 'test', 'foo_test.dart', 'bar_test.dart'], + command: [ + endsWith('flutter'), + 'test', + 'foo_test.dart', + 'bar_test.dart', + ], workingDirectory: exampleFlutterAppRoot.path, )), equalsCommand(( - command: ['dart', 'test', 'zip_test.dart'], + command: [endsWith('dart'), 'test', 'zip_test.dart'], workingDirectory: dartCliAppRoot.path, )), ]); }); + + group('create', () { + test('creates a Dart project', () async { + testHarness.mcpClient.addRoot(dartCliAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: dartCliAppRoot.uri, + ParameterNames.directory: 'new_app', + ParameterNames.projectType: 'dart', + ParameterNames.template: 'cli', + }, + ); + await testHarness.callToolWithRetry(request); + + expect(testProcessManager.commandsRan, [ + equalsCommand(( + command: [ + endsWith('dart'), + 'create', + '--template', + 'cli', + 'new_app', + ], + workingDirectory: dartCliAppRoot.path, + )), + ]); + }); + + test('creates a Flutter project', () async { + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: exampleFlutterAppRoot.uri, + ParameterNames.directory: 'new_app', + ParameterNames.projectType: 'flutter', + ParameterNames.template: 'app', + }, + ); + await testHarness.callToolWithRetry(request); + + expect(testProcessManager.commandsRan, [ + equalsCommand(( + command: [ + endsWith('flutter'), + 'create', + '--template', + 'app', + '--empty', + 'new_app', + ], + workingDirectory: exampleFlutterAppRoot.path, + )), + ]); + }); + + test('creates a non-empty Flutter project', () async { + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: exampleFlutterAppRoot.uri, + ParameterNames.directory: 'new_full_app', + ParameterNames.projectType: 'flutter', + ParameterNames.template: 'app', + ParameterNames.empty: + false, // Explicitly create a non-empty project + }, + ); + await testHarness.callToolWithRetry(request); + + expect(testProcessManager.commandsRan, [ + equalsCommand(( + command: [ + endsWith('flutter'), + 'create', + '--template', + 'app', + // Note: --empty is NOT present + 'new_full_app', + ], + workingDirectory: exampleFlutterAppRoot.path, + )), + ]); + }); + + test('fails with invalid platform for Flutter project', () async { + testHarness.mcpClient.addRoot(exampleFlutterAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: exampleFlutterAppRoot.uri, + ParameterNames.directory: 'my_app_invalid_platform', + ParameterNames.projectType: 'flutter', + ParameterNames.platform: ['atari_jaguar', 'web'], // One invalid + }, + ); + final result = await testHarness.callToolWithRetry( + request, + expectError: true, + ); + + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + allOf( + contains('atari_jaguar is not a valid platform.'), + contains( + 'Platforms `web`, `linux`, `macos`, `windows`, `android`, `ios` ' + 'are the only allowed values', + ), + ), + ); + expect(testProcessManager.commandsRan, isEmpty); + }); + + test('fails if projectType is missing', () async { + testHarness.mcpClient.addRoot(dartCliAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: dartCliAppRoot.uri, + ParameterNames.directory: 'my_app_no_type', + }, + ); + final result = await testHarness.callToolWithRetry( + request, + expectError: true, + ); + + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + contains('Required property "projectType" is missing'), + ); + expect(testProcessManager.commandsRan, isEmpty); + }); + + test('fails with invalid projectType', () async { + testHarness.mcpClient.addRoot(dartCliAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: dartCliAppRoot.uri, + ParameterNames.directory: 'my_app_invalid_type', + ParameterNames.projectType: 'java', // Invalid type + }, + ); + final result = await testHarness.callToolWithRetry( + request, + expectError: true, + ); + + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + contains('Only `dart` and `flutter` are allowed values.'), + ); + expect(testProcessManager.commandsRan, isEmpty); + }); + + test('fails if directory (project name) is an absolute path', () async { + testHarness.mcpClient.addRoot(dartCliAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.root: dartCliAppRoot.uri, + ParameterNames.directory: '/an/absolute/path/project', + ParameterNames.projectType: 'dart', + }, + ); + final result = await testHarness.callToolWithRetry( + request, + expectError: true, + ); + + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + contains('Directory must be a relative path'), + ); + expect(testProcessManager.commandsRan, isEmpty); + }); + + test('requires a root to be passed', () async { + testHarness.mcpClient.addRoot(dartCliAppRoot); + final request = CallToolRequest( + name: createProjectTool.name, + arguments: { + ParameterNames.directory: 'new_app', + ParameterNames.projectType: 'dart', + ParameterNames.template: 'cli', + }, + ); + final result = await testHarness.callToolWithRetry( + request, + expectError: true, + ); + + expect(result.isError, true); + expect( + (result.content.first as TextContent).text, + contains('missing `root` key'), + ); + expect(testProcessManager.commandsRan, isEmpty); + }); + }); }); }
diff --git a/pkgs/dart_mcp_server/test/tools/dtd_test.dart b/pkgs/dart_mcp_server/test/tools/dtd_test.dart index 83849e3..349a7fe 100644 --- a/pkgs/dart_mcp_server/test/tools/dtd_test.dart +++ b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
@@ -576,6 +576,64 @@ }); }); }); + + group('ErrorLog', () { + test('adds errors and respects max size', () { + final log = ErrorLog(maxSize: 10); + log.add('abc'); + expect(log.errors, ['abc']); + expect(log.characters, 3); + + log.add('defg'); + expect(log.errors, ['abc', 'defg']); + expect(log.characters, 7); + + log.add('hijkl'); + expect(log.errors, ['defg', 'hijkl']); + expect(log.characters, 9); + + log.add('mnopq'); + expect(log.errors, ['hijkl', 'mnopq']); + expect(log.characters, 10); + }); + + test('handles single error larger than max size', () { + final log = ErrorLog(maxSize: 10); + log.add('abcdefghijkl'); + expect(log.errors, ['abcdefghij']); + expect(log.characters, 10); + + log.add('mnopqrstuvwxyz'); + expect(log.errors, ['mnopqrstuv']); + expect(log.characters, 10); + }); + + test('clear removes all errors', () { + final log = ErrorLog(maxSize: 10); + log + ..add('abc') + ..add('def'); + log.clear(); + expect(log.errors, isEmpty); + expect(log.characters, 0); + }); + + test('add, clear,clear and then add again', () { + final log = ErrorLog(maxSize: 10); + log + ..add('abc') + ..add('def'); + log.clear(); + expect(log.errors, isEmpty); + expect(log.characters, 0); + log.add('ghi'); + expect(log.errors, ['ghi']); + expect(log.characters, 3); + log.add('jklmnopqrstuv'); + expect(log.errors, ['jklmnopqrs']); + expect(log.characters, 10); + }); + }); } extension on Iterable<Resource> {
diff --git a/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart b/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart index eedf697..eca24f7 100644 --- a/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart +++ b/pkgs/dart_mcp_server/test/tools/pub_dev_search_test.dart
@@ -82,9 +82,6 @@ 'license:osi-approved', ], 'publisher': 'publisher:google.dev', - 'api': { - 'qualifiedNames': containsAll(['retry', 'retry.RetryOptions']), - }, }); }); }, _GoldenResponseClient.new); @@ -161,7 +158,7 @@ expect(result.isError, isTrue); expect( (result.content[0] as TextContent).text, - contains('No packages mached the query, consider simplifying it'), + contains('No packages matched the query, consider simplifying it'), ); }); },
diff --git a/pkgs/dart_mcp_server/test/tools/pub_test.dart b/pkgs/dart_mcp_server/test/tools/pub_test.dart index 8f19130..693b99e 100644 --- a/pkgs/dart_mcp_server/test/tools/pub_test.dart +++ b/pkgs/dart_mcp_server/test/tools/pub_test.dart
@@ -68,7 +68,7 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: [appKind, 'pub', 'add', 'foo'], + command: [endsWith(appKind), 'pub', 'add', 'foo'], workingDirectory: fakeAppPath, )), ]); @@ -91,7 +91,7 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: [appKind, 'pub', 'remove', 'foo'], + command: [endsWith(appKind), 'pub', 'remove', 'foo'], workingDirectory: fakeAppPath, )), ]); @@ -113,7 +113,7 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: [appKind, 'pub', 'get'], + command: [endsWith(appKind), 'pub', 'get'], workingDirectory: fakeAppPath, )), ]); @@ -135,7 +135,7 @@ expect(result.isError, isNot(true)); expect(testProcessManager.commandsRan, [ equalsCommand(( - command: [appKind, 'pub', 'upgrade'], + command: [endsWith(appKind), 'pub', 'upgrade'], workingDirectory: fakeAppPath, )), ]);
diff --git a/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart b/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart index 36b97e1..2392d30 100644 --- a/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart +++ b/pkgs/dart_mcp_server/test/utils/cli_utils_test.dart
@@ -5,6 +5,7 @@ import 'package:dart_mcp/server.dart'; import 'package:dart_mcp_server/src/utils/cli_utils.dart'; import 'package:dart_mcp_server/src/utils/constants.dart'; +import 'package:dart_mcp_server/src/utils/sdk.dart'; import 'package:file/memory.dart'; import 'package:process/process.dart'; import 'package:test/fake.dart'; @@ -35,12 +36,13 @@ ], }, ), - commandForRoot: (_, _) => 'testCommand', + commandForRoot: (_, _, _) => 'testCommand', arguments: ['a', 'b'], commandDescription: '', processManager: processManager, knownRoots: [Root(uri: 'file:///bar/')], fileSystem: fileSystem, + sdk: Sdk(), ); expect(result.isError, isNot(true)); expect(processManager.commandsRan, [ @@ -63,11 +65,12 @@ ], }, ), - commandForRoot: (_, _) => 'testCommand', + commandForRoot: (_, _, _) => 'testCommand', commandDescription: '', processManager: processManager, knownRoots: [Root(uri: 'file:///bar/')], fileSystem: fileSystem, + sdk: Sdk(), ); expect(result.isError, isNot(true)); expect(processManager.commandsRan, [ @@ -94,11 +97,12 @@ ], }, ), - commandForRoot: (_, _) => 'fake', + commandForRoot: (_, _, _) => 'fake', commandDescription: '', processManager: processManager, knownRoots: [Root(uri: 'file:///foo/')], fileSystem: fileSystem, + sdk: Sdk(), ); expect(result.isError, isTrue); expect( @@ -130,11 +134,12 @@ ], }, ), - commandForRoot: (_, _) => 'fake', + commandForRoot: (_, _, _) => 'fake', commandDescription: '', processManager: processManager, knownRoots: [Root(uri: 'file:///foo/')], fileSystem: fileSystem, + sdk: Sdk(), ); expect(result.isError, isTrue); expect(