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);
+ });
+ });
});
}