Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions pkgs/dart_mcp_server/lib/src/mixins/dash_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
),
);
}
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(
Expand All @@ -117,6 +140,13 @@ base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
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,
];

Expand Down Expand Up @@ -188,8 +218,30 @@ base mixin DashCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
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',
};
}
5 changes: 3 additions & 2 deletions pkgs/dart_mcp_server/lib/src/utils/cli_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,11 @@ ListSchema rootsSchema({bool supportsPaths = false}) => Schema.list(
);

final rootSchema = Schema.string(
title: 'The URI of the project root to run this tool in.',
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 '
'returned by a call to "listRoots".',
'allowed by the client. Must be a URI with a `file:` '
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this because one thing I'm seeing: the create_project tool gets called 3-4 times in a row by the LLM because the LLM doesn't know what roots it has. I'm going to add them into the context, but I added some words to the description so that it would at least know that it's supposed to be a file: uri and not a path like "." or "my_project".

'scheme (e.g. file:///absolute/path/to/root).',
);

/// Very thin extension type for a pubspec just containing what we need.
Expand Down
2 changes: 2 additions & 0 deletions pkgs/dart_mcp_server/lib/src/utils/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ 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';
Expand Down
2 changes: 1 addition & 1 deletion pkgs/dart_mcp_server/lib/src/utils/process_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ import 'package:process/process.dart';
/// 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;
}
61 changes: 61 additions & 0 deletions pkgs/dart_mcp_server/test/tools/dart_cli_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,74 @@ dependencies:
'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(
Expand Down