Skip to content

Commit b0a224c

Browse files
authored
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.
1 parent c24afc1 commit b0a224c

File tree

5 files changed

+373
-94
lines changed

5 files changed

+373
-94
lines changed

pkgs/dart_mcp_server/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ WIP. This package is still experimental and is likely to evolve quickly.
2323
| `hot_reload` | `runtime tool` | Performs a hot reload of the active Flutter application. |
2424
| `connect_dart_tooling_daemon`* | `configuration` | Connects to the locally running Dart Tooling Daemon. |
2525
| `get_active_location` | `editor` | Gets the active cursor position in the connected editor (if available). |
26+
| `run_tests` | `static tool` | Runs tests for the given project roots. |
27+
| `create_project` | `static tool` | Creates a new Dart or Flutter project. |
2628

2729
> *Experimental: may be removed.
2830

pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import 'dart:async';
66

77
import 'package:dart_mcp/server.dart';
8+
import 'package:path/path.dart' as p;
89

910
import '../utils/cli_utils.dart';
1011
import '../utils/constants.dart';
@@ -28,6 +29,7 @@ base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
2829
registerTool(dartFixTool, _runDartFixTool);
2930
registerTool(dartFormatTool, _runDartFormatTool);
3031
registerTool(runTestsTool, _runTests);
32+
registerTool(createProjectTool, _runCreateProjectTool);
3133
}
3234
}
3335
}
@@ -71,6 +73,60 @@ base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
7173
);
7274
}
7375

76+
/// Implementation of the [createProjectTool].
77+
Future<CallToolResult> _runCreateProjectTool(CallToolRequest request) async {
78+
final args = request.arguments;
79+
80+
final errors = createProjectTool.inputSchema.validate(args);
81+
final projectType = args?[ParameterNames.projectType] as String?;
82+
if (projectType != 'dart' && projectType != 'flutter') {
83+
errors.add(
84+
ValidationError(
85+
ValidationErrorType.itemInvalid,
86+
path: [ParameterNames.projectType],
87+
details: 'Only `dart` and `flutter` are allowed values.',
88+
),
89+
);
90+
}
91+
final directory = args![ParameterNames.directory] as String;
92+
if (p.isAbsolute(directory)) {
93+
errors.add(
94+
ValidationError(
95+
ValidationErrorType.itemInvalid,
96+
path: [ParameterNames.directory],
97+
details: 'Directory must be a relative path.',
98+
),
99+
);
100+
}
101+
102+
if (errors.isNotEmpty) {
103+
return CallToolResult(
104+
content: [
105+
for (final error in errors) Content.text(text: error.toErrorString()),
106+
],
107+
isError: true,
108+
);
109+
}
110+
111+
final template = args[ParameterNames.template] as String?;
112+
113+
final commandArgs = [
114+
'create',
115+
if (template != null && template.isNotEmpty) ...['--template', template],
116+
directory,
117+
];
118+
119+
return runCommandInRoot(
120+
request,
121+
arguments: commandArgs,
122+
commandForRoot: (_, _) => projectType!,
123+
commandDescription: '$projectType create',
124+
fileSystem: fileSystem,
125+
processManager: processManager,
126+
knownRoots: await roots,
127+
);
128+
}
129+
74130
static final dartFixTool = Tool(
75131
name: 'dart_fix',
76132
description: 'Runs `dart fix --apply` for the given project roots.',
@@ -97,4 +153,31 @@ base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
97153
properties: {ParameterNames.roots: rootsSchema(supportsPaths: true)},
98154
),
99155
);
156+
157+
static final createProjectTool = Tool(
158+
name: 'create_project',
159+
description: 'Creates a new Dart or Flutter project.',
160+
annotations: ToolAnnotations(
161+
title: 'Create project',
162+
destructiveHint: true,
163+
),
164+
inputSchema: Schema.object(
165+
properties: {
166+
ParameterNames.root: rootSchema,
167+
ParameterNames.directory: Schema.string(
168+
description:
169+
'The subdirectory in which to create the project, must '
170+
'be a relative path.',
171+
),
172+
ParameterNames.projectType: Schema.string(
173+
description: "The type of project: 'dart' or 'flutter'.",
174+
),
175+
ParameterNames.template: Schema.string(
176+
description:
177+
'The project template to use (e.g., "console-full", "app").',
178+
),
179+
},
180+
required: [ParameterNames.directory, ParameterNames.projectType],
181+
),
182+
);
100183
}

pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart

Lines changed: 142 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -91,103 +91,150 @@ Future<CallToolResult> runCommandInRoots(
9191
];
9292
}
9393

94-
final outputs = <TextContent>[];
94+
final outputs = <Content>[];
9595
for (var rootConfig in rootConfigs) {
96-
final rootUriString = rootConfig[ParameterNames.root] as String?;
97-
if (rootUriString == null) {
98-
// This shouldn't happen based on the schema, but handle defensively.
99-
return CallToolResult(
100-
content: [
101-
TextContent(text: 'Invalid root configuration: missing `root` key.'),
102-
],
103-
isError: true,
104-
);
105-
}
96+
final result = await runCommandInRoot(
97+
request,
98+
rootConfig: rootConfig,
99+
commandForRoot: commandForRoot,
100+
arguments: arguments,
101+
commandDescription: commandDescription,
102+
fileSystem: fileSystem,
103+
processManager: processManager,
104+
knownRoots: knownRoots,
105+
defaultPaths: defaultPaths,
106+
);
107+
if (result.isError == true) return result;
108+
outputs.addAll(result.content);
109+
}
110+
return CallToolResult(content: outputs);
111+
}
106112

107-
final root = _findRoot(rootUriString, knownRoots);
108-
if (root == null) {
109-
return CallToolResult(
110-
content: [
111-
TextContent(
112-
text:
113-
'Invalid root $rootUriString, must be under one of the '
114-
'registered project roots:\n\n${knownRoots.join('\n')}',
115-
),
116-
],
117-
isError: true,
118-
);
119-
}
113+
/// Runs [commandForRoot] in a single project root specified in the
114+
/// [request], with [arguments].
115+
///
116+
/// If [rootConfig] is passed, this will be used to read the root configuration,
117+
/// otherwise it is read directly off of `request.arguments`.
118+
///
119+
/// These [commandForRoot] plus [arguments] are passed directly to
120+
/// [ProcessManager.run].
121+
///
122+
/// The [commandDescription] is used in the output to describe the command
123+
/// being run. For example, if the command is `['dart', 'fix', '--apply']`, the
124+
/// command description might be `dart fix`.
125+
///
126+
/// [defaultPaths] may be specified if one or more path arguments are required
127+
/// for the command (e.g. `dart format <default paths>`). The paths can be
128+
/// absolute or relative paths that point to the directories on which the
129+
/// command should be run. For example, the `dart format` command may pass a
130+
/// default path of '.', which indicates that every Dart file in the working
131+
/// directory should be formatted. The value of `defaultPaths` will only be used
132+
/// if the [request]'s root configuration does not contain a set value for a
133+
/// root's 'paths'.
134+
Future<CallToolResult> runCommandInRoot(
135+
CallToolRequest request, {
136+
Map<String, Object?>? rootConfig,
137+
FutureOr<String> Function(Root, FileSystem) commandForRoot =
138+
defaultCommandForRoot,
139+
List<String> arguments = const [],
140+
required String commandDescription,
141+
required FileSystem fileSystem,
142+
required ProcessManager processManager,
143+
required List<Root> knownRoots,
144+
List<String> defaultPaths = const <String>[],
145+
}) async {
146+
rootConfig ??= request.arguments;
147+
final rootUriString = rootConfig?[ParameterNames.root] as String?;
148+
if (rootUriString == null) {
149+
// This shouldn't happen based on the schema, but handle defensively.
150+
return CallToolResult(
151+
content: [
152+
TextContent(text: 'Invalid root configuration: missing `root` key.'),
153+
],
154+
isError: true,
155+
);
156+
}
120157

121-
final rootUri = Uri.parse(rootUriString);
122-
if (rootUri.scheme != 'file') {
123-
return CallToolResult(
124-
content: [
125-
TextContent(
126-
text:
127-
'Only file scheme uris are allowed for roots, but got '
128-
'$rootUri',
129-
),
130-
],
131-
isError: true,
132-
);
133-
}
134-
final projectRoot = fileSystem.directory(rootUri);
158+
final root = _findRoot(rootUriString, knownRoots);
159+
if (root == null) {
160+
return CallToolResult(
161+
content: [
162+
TextContent(
163+
text:
164+
'Invalid root $rootUriString, must be under one of the '
165+
'registered project roots:\n\n${knownRoots.join('\n')}',
166+
),
167+
],
168+
isError: true,
169+
);
170+
}
135171

136-
final commandWithPaths = <String>[
137-
await commandForRoot(root, fileSystem),
138-
...arguments,
139-
];
140-
final paths =
141-
(rootConfig[ParameterNames.paths] as List?)?.cast<String>() ??
142-
defaultPaths;
143-
final invalidPaths = paths.where((path) {
144-
final resolvedPath = rootUri.resolve(path).toString();
145-
return rootUriString != resolvedPath &&
146-
!p.isWithin(rootUriString, resolvedPath);
147-
});
148-
if (invalidPaths.isNotEmpty) {
149-
return CallToolResult(
150-
content: [
151-
TextContent(
152-
text:
153-
'Paths are not allowed to escape their project root:\n'
154-
'${invalidPaths.join('\n')}',
155-
),
156-
],
157-
isError: true,
158-
);
159-
}
160-
commandWithPaths.addAll(paths);
172+
final rootUri = Uri.parse(rootUriString);
173+
if (rootUri.scheme != 'file') {
174+
return CallToolResult(
175+
content: [
176+
TextContent(
177+
text:
178+
'Only file scheme uris are allowed for roots, but got '
179+
'$rootUri',
180+
),
181+
],
182+
isError: true,
183+
);
184+
}
185+
final projectRoot = fileSystem.directory(rootUri);
161186

162-
final result = await processManager.run(
163-
commandWithPaths,
164-
workingDirectory: projectRoot.path,
165-
runInShell: true,
187+
final commandWithPaths = <String>[
188+
await commandForRoot(root, fileSystem),
189+
...arguments,
190+
];
191+
final paths =
192+
(rootConfig?[ParameterNames.paths] as List?)?.cast<String>() ??
193+
defaultPaths;
194+
final invalidPaths = paths.where((path) {
195+
final resolvedPath = rootUri.resolve(path).toString();
196+
return rootUriString != resolvedPath &&
197+
!p.isWithin(rootUriString, resolvedPath);
198+
});
199+
if (invalidPaths.isNotEmpty) {
200+
return CallToolResult(
201+
content: [
202+
TextContent(
203+
text:
204+
'Paths are not allowed to escape their project root:\n'
205+
'${invalidPaths.join('\n')}',
206+
),
207+
],
208+
isError: true,
166209
);
210+
}
211+
commandWithPaths.addAll(paths);
167212

168-
final output = (result.stdout as String).trim();
169-
final errors = (result.stderr as String).trim();
170-
if (result.exitCode != 0) {
171-
return CallToolResult(
172-
content: [
173-
TextContent(
174-
text:
175-
'$commandDescription failed in ${projectRoot.path}:\n'
176-
'$output\n\nErrors\n$errors',
177-
),
178-
],
179-
isError: true,
180-
);
181-
}
182-
if (output.isNotEmpty) {
183-
outputs.add(
213+
final result = await processManager.run(
214+
commandWithPaths,
215+
workingDirectory: projectRoot.path,
216+
runInShell: true,
217+
);
218+
219+
final output = (result.stdout as String).trim();
220+
final errors = (result.stderr as String).trim();
221+
if (result.exitCode != 0) {
222+
return CallToolResult(
223+
content: [
184224
TextContent(
185-
text: '$commandDescription in ${projectRoot.path}:\n$output',
225+
text:
226+
'$commandDescription failed in ${projectRoot.path}:\n'
227+
'$output\n\nErrors\n$errors',
186228
),
187-
);
188-
}
229+
],
230+
isError: true,
231+
);
189232
}
190-
return CallToolResult(content: outputs);
233+
return CallToolResult(
234+
content: [
235+
TextContent(text: '$commandDescription in ${projectRoot.path}:\n$output'),
236+
],
237+
);
191238
}
192239

193240
/// Returns 'dart' or 'flutter' based on the pubspec contents.
@@ -224,12 +271,7 @@ ListSchema rootsSchema({bool supportsPaths = false}) => Schema.list(
224271
title: 'All projects roots to run this tool in.',
225272
items: Schema.object(
226273
properties: {
227-
ParameterNames.root: Schema.string(
228-
title: 'The URI of the project root to run this tool in.',
229-
description:
230-
'This must be equal to or a subdirectory of one of the roots '
231-
'returned by a call to "listRoots".',
232-
),
274+
ParameterNames.root: rootSchema,
233275
if (supportsPaths)
234276
ParameterNames.paths: Schema.list(
235277
title:
@@ -242,6 +284,13 @@ ListSchema rootsSchema({bool supportsPaths = false}) => Schema.list(
242284
),
243285
);
244286

287+
final rootSchema = Schema.string(
288+
title: 'The URI of the project root to run this tool in.',
289+
description:
290+
'This must be equal to or a subdirectory of one of the roots '
291+
'returned by a call to "listRoots".',
292+
);
293+
245294
/// Very thin extension type for a pubspec just containing what we need.
246295
///
247296
/// We assume a valid pubspec.

0 commit comments

Comments
 (0)