Skip to content

Require roots for all CLI tools #101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 2, 2025
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
2 changes: 1 addition & 1 deletion pkgs/dart_tooling_mcp_server/lib/src/mixins/analyzer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ base mixin DartAnalyzerSupport
return CallToolResult(content: [TextContent(text: jsonEncode(result))]);
}

/// Ensures that all prerequites for any analysis task are met.
/// Ensures that all prerequisites for any analysis task are met.
///
/// Returns an error response if any prerequisite is not met, otherwise
/// returns `null`.
Expand Down
58 changes: 15 additions & 43 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'dart:async';
import 'package:dart_mcp/server.dart';

import '../utils/cli_utils.dart';
import '../utils/constants.dart';
import '../utils/process_manager.dart';

// TODO: migrate the analyze files tool to use this mixin and run the
Expand All @@ -17,13 +18,19 @@ import '../utils/process_manager.dart';
///
/// The MCPServer must already have the [ToolsSupport] and [LoggingSupport]
/// mixins applied.
base mixin DartCliSupport on ToolsSupport, LoggingSupport
base mixin DartCliSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
implements ProcessManagerSupport {
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) {
registerTool(dartFixTool, _runDartFixTool);
registerTool(dartFormatTool, _runDartFormatTool);
return super.initialize(request);
try {
return super.initialize(request);
} finally {
// Can't call this until after `super.initialize`.
if (supportsRoots) {
registerTool(dartFixTool, _runDartFixTool);
registerTool(dartFormatTool, _runDartFormatTool);
}
}
}

/// Implementation of the [dartFixTool].
Expand All @@ -33,6 +40,7 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
command: ['dart', 'fix', '--apply'],
commandDescription: 'dart fix',
processManager: processManager,
knownRoots: await roots,
);
}

Expand All @@ -44,6 +52,7 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
commandDescription: 'dart format',
processManager: processManager,
defaultPaths: ['.'],
knownRoots: await roots,
);
}

Expand All @@ -52,22 +61,7 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
description: 'Runs `dart fix --apply` for the given project roots.',
annotations: ToolAnnotations(title: 'Dart fix', destructiveHint: true),
inputSchema: Schema.object(
properties: {
'roots': Schema.list(
title: 'All projects roots to run dart fix in.',
description:
'These must match a root returned by a call to "listRoots".',
items: Schema.object(
properties: {
'root': Schema.string(
title: 'The URI of the project root to run `dart fix` in.',
),
},
required: ['root'],
),
),
},
required: ['roots'],
properties: {ParameterNames.roots: rootsSchema()},
),
);

Expand All @@ -76,29 +70,7 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
description: 'Runs `dart format .` for the given project roots.',
annotations: ToolAnnotations(title: 'Dart format', destructiveHint: true),
inputSchema: Schema.object(
properties: {
'roots': Schema.list(
title: 'All projects roots to run dart format in.',
description:
'These must match a root returned by a call to "listRoots".',
items: Schema.object(
properties: {
'root': Schema.string(
title: 'The URI of the project root to run `dart format` in.',
),
'paths': Schema.list(
title:
'Relative or absolute paths to analyze under the '
'"root". Paths must correspond to files and not '
'directories.',
items: Schema.string(),
),
},
required: ['root'],
),
),
},
required: ['roots'],
properties: {ParameterNames.roots: rootsSchema(supportsPaths: true)},
),
);
}
40 changes: 17 additions & 23 deletions pkgs/dart_tooling_mcp_server/lib/src/mixins/pub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'dart:async';
import 'package:dart_mcp/server.dart';

import '../utils/cli_utils.dart';
import '../utils/constants.dart';
import '../utils/process_manager.dart';

/// Mix this in to any MCPServer to add support for running Pub commands like
Expand All @@ -16,17 +17,22 @@ import '../utils/process_manager.dart';
///
/// The MCPServer must already have the [ToolsSupport] and [LoggingSupport]
/// mixins applied.
base mixin PubSupport on ToolsSupport, LoggingSupport
base mixin PubSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport
implements ProcessManagerSupport {
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) {
registerTool(pubTool, _runDartPubTool);
return super.initialize(request);
try {
return super.initialize(request);
} finally {
if (supportsRoots) {
registerTool(pubTool, _runDartPubTool);
}
}
}

/// Implementation of the [pubTool].
Future<CallToolResult> _runDartPubTool(CallToolRequest request) async {
final command = request.arguments?['command'] as String?;
final command = request.arguments?[ParameterNames.command] as String?;
if (command == null) {
return CallToolResult(
content: [TextContent(text: 'Missing required argument `command`.')],
Expand All @@ -48,7 +54,8 @@ base mixin PubSupport on ToolsSupport, LoggingSupport
);
}

final packageName = request.arguments?['packageName'] as String?;
final packageName =
request.arguments?[ParameterNames.packageName] as String?;
if (matchingCommand.requiresPackageName && packageName == null) {
return CallToolResult(
content: [
Expand All @@ -69,6 +76,7 @@ base mixin PubSupport on ToolsSupport, LoggingSupport
command: ['dart', 'pub', command, if (packageName != null) packageName],
commandDescription: 'dart pub $command',
processManager: processManager,
knownRoots: await roots,
);
}

Expand All @@ -80,34 +88,20 @@ base mixin PubSupport on ToolsSupport, LoggingSupport
annotations: ToolAnnotations(title: 'pub', readOnlyHint: false),
inputSchema: Schema.object(
properties: {
'command': Schema.string(
ParameterNames.command: Schema.string(
title: 'The dart pub command to run.',
description:
'Currently only ${SupportedPubCommand.listAll} are supported.',
),
'packageName': Schema.string(
ParameterNames.packageName: Schema.string(
title: 'The package name to run the command for.',
description:
'This is required for the '
'${SupportedPubCommand.listAllThatRequirePackageName} commands.',
),
'roots': Schema.list(
title: 'All projects roots to run the dart pub command in.',
description:
'These must match a root returned by a call to "listRoots".',
items: Schema.object(
properties: {
'root': Schema.string(
title:
'The URI of the project root to run the dart pub command '
'in.',
),
},
required: ['root'],
),
),
ParameterNames.roots: rootsSchema(),
},
required: ['command', 'roots'],
required: [ParameterNames.command],
),
);
}
Expand Down
96 changes: 85 additions & 11 deletions pkgs/dart_tooling_mcp_server/lib/src/utils/cli_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
import 'dart:io';

import 'package:dart_mcp/server.dart';
import 'package:path/path.dart' as p;
import 'package:process/process.dart';

import 'constants.dart';

/// Runs [command] in each of the project roots specified in the [request].
///
/// The [command] should be a list of strings that can be passed directly to
Expand All @@ -16,6 +19,10 @@ import 'package:process/process.dart';
/// being run. For example, if the command is `['dart', 'fix', '--apply']`, the
/// command description might be `dart fix`.
///
/// The [knownRoots] are used by default if no roots are provided as an
/// argument on the [request]. Otherwise, all roots provided in the request
/// arguments must still be encapsulated by the [knownRoots].
///
/// [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
Expand All @@ -29,20 +36,23 @@ Future<CallToolResult> runCommandInRoots(
required List<String> command,
required String commandDescription,
required ProcessManager processManager,
required List<Root> knownRoots,
List<String> defaultPaths = const <String>[],
}) async {
final rootConfigs =
(request.arguments?['roots'] as List?)?.cast<Map<String, Object?>>();
if (rootConfigs == null) {
return CallToolResult(
content: [TextContent(text: 'Missing required argument `roots`.')],
isError: true,
);
var rootConfigs =
(request.arguments?[ParameterNames.roots] as List?)
?.cast<Map<String, Object?>>();

// Default to use the known roots if none were specified.
if (rootConfigs == null || rootConfigs.isEmpty) {
rootConfigs = [
for (final root in knownRoots) {ParameterNames.root: root.uri},
];
}

final outputs = <TextContent>[];
for (var rootConfig in rootConfigs) {
final rootUriString = rootConfig['root'] as String?;
final rootUriString = rootConfig[ParameterNames.root] as String?;
if (rootUriString == null) {
// This shouldn't happen based on the schema, but handle defensively.
return CallToolResult(
Expand All @@ -53,6 +63,19 @@ Future<CallToolResult> runCommandInRoots(
);
}

if (!_isAllowedRoot(rootUriString, knownRoots)) {
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(
Expand All @@ -68,9 +91,28 @@ Future<CallToolResult> runCommandInRoots(
}
final projectRoot = Directory(rootUri.toFilePath());

final commandWithPaths = List<String>.from(command);
final paths = (rootConfig['paths'] as List?)?.cast<String>();
commandWithPaths.addAll(paths ?? defaultPaths);
final commandWithPaths = List.of(command);
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,
Expand Down Expand Up @@ -102,3 +144,35 @@ Future<CallToolResult> runCommandInRoots(
}
return CallToolResult(content: outputs);
}

/// Returns whether or not [rootUri] is an allowed root, either exactly matching
/// or under on of the [knownRoots].
bool _isAllowedRoot(String rootUri, List<Root> knownRoots) =>
knownRoots.any((knownRoot) {
final knownRootUri = Uri.parse(knownRoot.uri);
final resolvedRoot = knownRootUri.resolve(rootUri).toString();
return knownRoot.uri == resolvedRoot ||
p.isWithin(knownRoot.uri, resolvedRoot);
});

/// The schema for the `roots` parameter for any tool that accepts it.
ListSchema rootsSchema({bool supportsPaths = false}) => Schema.list(
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".',
),
if (supportsPaths)
ParameterNames.paths: Schema.list(
title:
'Paths to run this tool on. Must resolve to a path that is '
'within the "root".',
),
},
required: [ParameterNames.root],
),
);
12 changes: 12 additions & 0 deletions pkgs/dart_tooling_mcp_server/lib/src/utils/constants.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 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.

/// A namespace for all the parameter names.
extension ParameterNames on Never {
Copy link
Contributor

Choose a reason for hiding this comment

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

why extend Never rather than just creating a new abstract class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Extensions are a bit more restricted (can't be implemented/extended etc), and don't create a real type (they disappear at runtime entirely) so they are technically cheaper.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note also that static methods on extensions are not actually added to Never either, so this has no impact on the Never type anywhere it appears.

static const command = 'command';
static const packageName = 'packageName';
static const paths = 'paths';
static const root = 'root';
static const roots = 'roots';
}
Loading