Flutter/Dart create tool (#140)

Adds a create_project tool, which takes a project kind (flutter or dart) and runs the appropriate command.

Requires a root to keep things on the rails, but accepts a relative path anywhere under the root to create the app in.

Also accepts a template configuration.
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/lib/src/mixins/dash_cli.dart b/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
index 7784dee..075540b 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
@@ -5,6 +5,7 @@
 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';
@@ -28,6 +29,7 @@
         registerTool(dartFixTool, _runDartFixTool);
         registerTool(dartFormatTool, _runDartFormatTool);
         registerTool(runTestsTool, _runTests);
+        registerTool(createProjectTool, _runCreateProjectTool);
       }
     }
   }
@@ -71,6 +73,60 @@
     );
   }
 
+  /// 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.',
+        ),
+      );
+    }
+
+    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],
+      directory,
+    ];
+
+    return runCommandInRoot(
+      request,
+      arguments: commandArgs,
+      commandForRoot: (_, _) => projectType!,
+      commandDescription: '$projectType create',
+      fileSystem: fileSystem,
+      processManager: processManager,
+      knownRoots: await roots,
+    );
+  }
+
   static final dartFixTool = Tool(
     name: 'dart_fix',
     description: 'Runs `dart fix --apply` for the given project roots.',
@@ -97,4 +153,31 @@
       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").',
+        ),
+      },
+      required: [ParameterNames.directory, ParameterNames.projectType],
+    ),
+  );
 }
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..4fce8b7 100644
--- a/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
+++ b/pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
@@ -91,105 +91,152 @@
     ];
   }
 
-  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,
     );
-
-    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) commandForRoot =
+      defaultCommandForRoot,
+  List<String> arguments = const [],
+  required String commandDescription,
+  required FileSystem fileSystem,
+  required ProcessManager processManager,
+  required List<Root> knownRoots,
+  List<String> defaultPaths = const <String>[],
+}) 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),
+    ...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.
@@ -224,12 +271,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 +284,13 @@
   ),
 );
 
+final rootSchema = 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".',
+);
+
 /// 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..f6fc543 100644
--- a/pkgs/dart_mcp_server/lib/src/utils/constants.dart
+++ b/pkgs/dart_mcp_server/lib/src/utils/constants.dart
@@ -8,14 +8,17 @@
 extension ParameterNames on Never {
   static const column = 'column';
   static const command = 'command';
+  static const directory = 'directory';
   static const line = 'line';
   static const name = 'name';
   static const packageName = 'packageName';
   static const paths = 'paths';
   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/test/tools/dart_cli_test.dart b/pkgs/dart_mcp_server/test/tools/dart_cli_test.dart
index d63677f..9bfdc93 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: {
@@ -79,6 +83,7 @@
     });
 
     test('dart format', () async {
+      testHarness.mcpClient.addRoot(exampleFlutterAppRoot);
       final request = CallToolRequest(
         name: dartFormatTool.name,
         arguments: {
@@ -100,6 +105,7 @@
     });
 
     test('dart format with paths', () async {
+      testHarness.mcpClient.addRoot(exampleFlutterAppRoot);
       final request = CallToolRequest(
         name: dartFormatTool.name,
         arguments: {
@@ -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,
@@ -156,5 +163,140 @@
         )),
       ]);
     });
+
+    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: ['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: ['flutter', 'create', '--template', 'app', 'new_app'],
+            workingDirectory: exampleFlutterAppRoot.path,
+          )),
+        ]);
+      });
+
+      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);
+      });
+    });
   });
 }