blob: 8b3ba0a2ba57a4fec150e782ea407ae2058816fd [file] [log] [blame]
// 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.
import 'dart:async';
import 'dart:io';
import 'package:dart_mcp/server.dart';
import '../utils/process_manager.dart';
// TODO: migrate the analyze files tool to use this mixin and run the
// `dart analyze` command instead of the analyzer package.
/// Mix this in to any MCPServer to add support for running Dart CLI commands
/// like `dart fix` and `dart format`.
///
/// The MCPServer must already have the [ToolsSupport] and [LoggingSupport]
/// mixins applied.
base mixin DartCliSupport on ToolsSupport, LoggingSupport
implements ProcessManagerSupport {
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) {
if (request.capabilities.roots == null) {
throw StateError(
'This server requires the "roots" capability to be implemented.',
);
}
registerTool(dartFixTool, _runDartFixTool);
registerTool(dartFormatTool, _runDartFormatTool);
registerTool(dartPubTool, _runDartPubTool);
return super.initialize(request);
}
/// Implementation of the [dartFixTool].
Future<CallToolResult> _runDartFixTool(CallToolRequest request) async {
return _runDartCommandInRoots(
request,
commandDescription: 'dart fix',
commandArgs: ['fix', '--apply'],
);
}
/// Implementation of the [dartFormatTool].
Future<CallToolResult> _runDartFormatTool(CallToolRequest request) async {
return _runDartCommandInRoots(
request,
commandDescription: 'dart format',
commandArgs: ['format'],
defaultPaths: ['.'],
);
}
/// Implementation of the [dartPubTool].
Future<CallToolResult> _runDartPubTool(CallToolRequest request) async {
final command = request.arguments?['command'] as String?;
if (command == null) {
return CallToolResult(
content: [TextContent(text: 'Missing required argument `command`.')],
isError: true,
);
}
final matchingCommand = SupportedPubCommand.fromName(command);
if (matchingCommand == null) {
return CallToolResult(
content: [
TextContent(
text:
'Unsupported pub command `$command`. Currently, the supported '
'commands are: '
'${SupportedPubCommand.values.map((e) => e.name).join(', ')}',
),
],
isError: true,
);
}
final packageName = request.arguments?['packageName'] as String?;
if (matchingCommand.requiresPackageName && packageName == null) {
return CallToolResult(
content: [
TextContent(
text:
'Missing required argument `packageName` for the `$command` '
'command.',
),
],
isError: true,
);
}
return _runDartCommandInRoots(
request,
commandDescription: 'dart pub $command',
commandArgs: ['pub', command, if (packageName != null) packageName],
);
}
/// Helper to run a dart command in multiple project roots.
///
/// [defaultPaths] may be specified if one or more path arguments are required
/// for the dart command (e.g. `dart format <default paths>`).
Future<CallToolResult> _runDartCommandInRoots(
CallToolRequest request, {
required String commandDescription,
required List<String> commandArgs,
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,
);
}
final outputs = <TextContent>[];
for (var rootConfig in rootConfigs) {
final rootUriString = rootConfig['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 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 = Directory(rootUri.toFilePath());
final paths = (rootConfig['paths'] as List?)?.cast<String>();
if (paths != null) {
commandArgs.addAll(paths);
} else {
commandArgs.addAll(defaultPaths);
}
final result = await processManager.run(
['dart', ...commandArgs],
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,
);
}
if (output.isNotEmpty) {
outputs.add(
TextContent(
text: '$commandDescription in ${projectRoot.path}:\n$output',
),
);
}
}
return CallToolResult(content: outputs);
}
static final dartPubTool = Tool(
name: 'dart_pub',
description:
'Runs a dart pub command for the given project roots, like `dart pub '
'get` or `dart pub add`.',
inputSchema: ObjectSchema(
properties: {
'command': StringSchema(
title: 'The dart pub command to run.',
description:
'Currently only ${SupportedPubCommand.listAll} are supported.',
),
'packageName': StringSchema(
title: 'The package name to run the command for.',
description:
'This is required for the '
'${SupportedPubCommand.listAllThatRequirePackageName} commands.',
),
'roots': ListSchema(
title: 'All projects roots to run the dart pub command in.',
description:
'These must match a root returned by a call to "listRoots".',
items: ObjectSchema(
properties: {
'root': StringSchema(
title:
'The URI of the project root to run the dart pub command '
'in.',
),
},
required: ['root'],
),
),
},
required: ['command', 'roots'],
),
);
static final dartFixTool = Tool(
name: 'dart_fix',
description: 'Runs `dart fix --apply` for the given project roots.',
inputSchema: ObjectSchema(
properties: {
'roots': ListSchema(
title: 'All projects roots to run dart fix in.',
description:
'These must match a root returned by a call to "listRoots".',
items: ObjectSchema(
properties: {
'root': StringSchema(
title: 'The URI of the project root to run `dart fix` in.',
),
},
required: ['root'],
),
),
},
required: ['roots'],
),
);
static final dartFormatTool = Tool(
name: 'dart_format',
description: 'Runs `dart format .` for the given project roots.',
inputSchema: ObjectSchema(
properties: {
'roots': ListSchema(
title: 'All projects roots to run dart format in.',
description:
'These must match a root returned by a call to "listRoots".',
items: ObjectSchema(
properties: {
'root': StringSchema(
title: 'The URI of the project root to run `dart format` in.',
),
'paths': ListSchema(
title:
'Relative or absolute paths to analyze under the '
'"root". Paths must correspond to files and not '
'directories.',
items: StringSchema(),
),
},
required: ['root'],
),
),
},
required: ['roots'],
),
);
}
/// The set of supported `dart pub` subcommands.
enum SupportedPubCommand {
// This is supported in a simplified form: `dart pub add <package-name>`.
// TODO(https://github.com/dart-lang/ai/issues/77): add support for adding
// dev dependencies.
add(requiresPackageName: true),
get,
// This is supported in a simplified form: `dart pub remove <package-name>`.
remove(requiresPackageName: true),
upgrade;
const SupportedPubCommand({this.requiresPackageName = false});
final bool requiresPackageName;
static SupportedPubCommand? fromName(String name) {
for (final command in SupportedPubCommand.values) {
if (command.name == name) {
return command;
}
}
return null;
}
static String get listAll {
return _writeCommandsAsList(SupportedPubCommand.values);
}
static String get listAllThatRequirePackageName {
return _writeCommandsAsList(
SupportedPubCommand.values.where((c) => c.requiresPackageName).toList(),
);
}
static String _writeCommandsAsList(List<SupportedPubCommand> commands) {
final buffer = StringBuffer();
for (var i = 0; i < commands.length; i++) {
final commandName = commands[i].name;
buffer.write('`$commandName`');
if (i < commands.length - 2) {
buffer.write(', ');
} else if (i == commands.length - 2) {
buffer.write(' and ');
}
}
return buffer.toString();
}
}