Repository: dart-lang/ai
Pull request #76: [dart_tooling_mcp_server] Add a dart pub tool
Author: login: kenzieschmoll
Labels: name: package:dart_mcp_server
Created: 2025-04-21T23:56:49Z | Merged: 2025-04-22T23:17:34Z

The initial set of supported pub commands are `add`, `remove`, `get`, and `upgrade`.

Requires https://github.com/dart-lang/ai/pull/75 to land first.


Diff:
diff --git a/pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart b/pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
index 2f18a8ae..8b3ba0a2 100644
--- a/pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
+++ b/pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
@@ -28,6 +28,7 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
     }
     registerTool(dartFixTool, _runDartFixTool);
     registerTool(dartFormatTool, _runDartFormatTool);
+    registerTool(dartPubTool, _runDartPubTool);
     return super.initialize(request);
   }
 
@@ -50,6 +51,51 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
     );
   }
 
+  /// 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
@@ -137,6 +183,44 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
     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.',
@@ -190,3 +274,55 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport
     ),
   );
 }
+
+/// 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();
+  }
+}
diff --git a/pkgs/dart_tooling_mcp_server/test/test_harness.dart b/pkgs/dart_tooling_mcp_server/test/test_harness.dart
index ba938862..0dc4a766 100644
--- a/pkgs/dart_tooling_mcp_server/test/test_harness.dart
+++ b/pkgs/dart_tooling_mcp_server/test/test_harness.dart
@@ -133,6 +133,7 @@ class TestHarness {
   Future<CallToolResult> callToolWithRetry(
     CallToolRequest request, {
     int maxTries = 5,
+    bool expectError = false,
   }) async {
     var tryCount = 0;
     late CallToolResult lastResult;
@@ -143,7 +144,7 @@ class TestHarness {
     }
     expect(
       lastResult.isError,
-      isNot(true),
+      expectError ? true : isNot(true),
       reason: lastResult.content.join('\n'),
     );
     return lastResult;
diff --git a/pkgs/dart_tooling_mcp_server/test/tools/dart_cli_test.dart b/pkgs/dart_tooling_mcp_server/test/tools/dart_cli_test.dart
index 843a0e59..3896ef46 100644
--- a/pkgs/dart_tooling_mcp_server/test/tools/dart_cli_test.dart
+++ b/pkgs/dart_tooling_mcp_server/test/tools/dart_cli_test.dart
@@ -32,6 +32,7 @@ void main() {
   group('dart cli tools', () {
     late Tool dartFixTool;
     late Tool dartFormatTool;
+    late Tool dartPubTool;
 
     setUp(() async {
       final tools = (await testHarness.mcpServerConnection.listTools()).tools;
@@ -41,6 +42,9 @@ void main() {
       dartFormatTool = tools.singleWhere(
         (t) => t.name == DartCliSupport.dartFormatTool.name,
       );
+      dartPubTool = tools.singleWhere(
+        (t) => t.name == DartCliSupport.dartPubTool.name,
+      );
     });
 
     test('dart fix', () async {
@@ -99,5 +103,157 @@ void main() {
         ['dart', 'format', 'foo.dart', 'bar.dart'],
       ]);
     });
+
+    group('dart pub', () {
+      test('add', () async {
+        final request = CallToolRequest(
+          name: dartPubTool.name,
+          arguments: {
+            'command': 'add',
+            'packageName': 'foo',
+            'roots': [
+              {'root': testRoot.uri},
+            ],
+          },
+        );
+        final result = await testHarness.callToolWithRetry(request);
+
+        // Verify the command was sent to the process maanger without error.
+        expect(result.isError, isNot(true));
+        expect(testProcessManager.commandsRan, [
+          ['dart', 'pub', 'add', 'foo'],
+        ]);
+      });
+
+      test('remove', () async {
+        final request = CallToolRequest(
+          name: dartPubTool.name,
+          arguments: {
+            'command': 'remove',
+            'packageName': 'foo',
+            'roots': [
+              {'root': testRoot.uri},
+            ],
+          },
+        );
+        final result = await testHarness.callToolWithRetry(request);
+
+        // Verify the command was sent to the process maanger without error.
+        expect(result.isError, isNot(true));
+        expect(testProcessManager.commandsRan, [
+          ['dart', 'pub', 'remove', 'foo'],
+        ]);
+      });
+
+      test('get', () async {
+        final request = CallToolRequest(
+          name: dartPubTool.name,
+          arguments: {
+            'command': 'get',
+            'roots': [
+              {'root': testRoot.uri},
+            ],
+          },
+        );
+        final result = await testHarness.callToolWithRetry(request);
+
+        // Verify the command was sent to the process maanger without error.
+        expect(result.isError, isNot(true));
+        expect(testProcessManager.commandsRan, [
+          ['dart', 'pub', 'get'],
+        ]);
+      });
+
+      test('upgrade', () async {
+        final request = CallToolRequest(
+          name: dartPubTool.name,
+          arguments: {
+            'command': 'upgrade',
+            'roots': [
+              {'root': testRoot.uri},
+            ],
+          },
+        );
+        final result = await testHarness.callToolWithRetry(request);
+
+        // Verify the command was sent to the process maanger without error.
+        expect(result.isError, isNot(true));
+        expect(testProcessManager.commandsRan, [
+          ['dart', 'pub', 'upgrade'],
+        ]);
+      });
+
+      group('returns error', () {
+        test('for missing command', () async {
+          final request = CallToolRequest(name: dartPubTool.name);
+          final result = await testHarness.callToolWithRetry(
+            request,
+            expectError: true,
+          );
+
+          expect(
+            (result.content.single as TextContent).text,
+            'Missing required argument `command`.',
+          );
+          expect(testProcessManager.commandsRan, isEmpty);
+        });
+
+        test('for unsupported command', () async {
+          final request = CallToolRequest(
+            name: dartPubTool.name,
+            arguments: {'command': 'publish'},
+          );
+          final result = await testHarness.callToolWithRetry(
+            request,
+            expectError: true,
+          );
+
+          expect(
+            (result.content.single as TextContent).text,
+            contains('Unsupported pub command `publish`.'),
+          );
+          expect(testProcessManager.commandsRan, isEmpty);
+        });
+
+        for (final command in SupportedPubCommand.values.where(
+          (c) => c.requiresPackageName,
+        )) {
+          test('for missing package name: $command', () async {
+            final request = CallToolRequest(
+              name: dartPubTool.name,
+              arguments: {'command': command.name},
+            );
+            final result = await testHarness.callToolWithRetry(
+              request,
+              expectError: true,
+            );
+
+            expect(
+              (result.content.single as TextContent).text,
+              'Missing required argument `packageName` for the '
+              '`${command.name}` command.',
+            );
+            expect(testProcessManager.commandsRan, isEmpty);
+          });
+        }
+
+        test('for missing roots', () async {
+          final request = CallToolRequest(
+            name: dartPubTool.name,
+            arguments: {'command': 'get'},
+          );
+          final result = await testHarness.callToolWithRetry(
+            request,
+            expectError: true,
+          );
+
+          expect(
+            (result.content.single as TextContent).text,
+            'Missing required argument `roots`.',
+          );
+          expect(testProcessManager.commandsRan, isEmpty);
+        });
+      });
+    });
   });
 }


Reviews:
user: login: jakemac53
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T14:13:15Z
user: login: jakemac53
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T14:14:23Z
user: login: kenzieschmoll
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:03:16Z
user: login: kenzieschmoll
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:06:49Z
user: login: jakemac53
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:04:53Z
user: login: kenzieschmoll
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:14:09Z
user: login: jakemac53
body: LGTM - see comment regarding dart vs flutter "pub" commands.

I am fine with addressing this as a follow-up, but removed auto-submit in case you do want to address it now.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:16:05Z
user: login: jakemac53
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:16:14Z
user: login: jakemac53
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:16:41Z
user: login: jakemac53
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:17:17Z
user: login: kenzieschmoll
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:42:52Z

Review comments:
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: jakemac53
body: I think we should try and make this a general "pub" tool, and it should detect whether it should use `dart pub` or `flutter pub` in each of the given roots based on project type.
created_at: 2025-04-22T14:13:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -182,3 +274,53 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
     ),
   );
 }
+
+/// The set of supported `dart pub` subcommands.
+enum SupportedPubCommand {
+  // This is supported in a simplified form: `dart pub add <package-name>`.
+  add(requiresPackageName: true),
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: jakemac53
body: This one should support dev-only packages as well imo.
created_at: 2025-04-22T14:14:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: kenzieschmoll
body: Do `dart pub` and `flutter pub` actually behave differently? Or does `flutter pub` just call into `dart pub` from Flutter tools?
created_at: 2025-04-22T16:03:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -182,3 +274,53 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
     ),
   );
 }
+
+/// The set of supported `dart pub` subcommands.
+enum SupportedPubCommand {
+  // This is supported in a simplified form: `dart pub add <package-name>`.
+  add(requiresPackageName: true),
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: kenzieschmoll
body: Added a TODO to add this support: https://github.com/dart-lang/ai/issues/77
created_at: 2025-04-22T16:06:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: jakemac53
body: They do behave differently, `dart pub` does not work in flutter projects ~and `flutter pub` does not work in dart projects.~ actually `flutter pub` does work in dart projects?
created_at: 2025-04-22T23:04:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: kenzieschmoll
body: I also just checked on a flutter project and `dart pub` works there as well.
created_at: 2025-04-22T23:14:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: jakemac53
body: ```
Because counter_app depends on flutter_test from sdk which doesn't exist (the
  Flutter SDK is not available), version solving failed.

Flutter users should use `flutter pub` instead of `dart pub`.
```
created_at: 2025-04-22T23:16:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: jakemac53
body: (it doesn't work if you have any flutter SDK deps)
created_at: 2025-04-22T23:17:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,22 +165,62 @@ base mixin DartCliSupport on ToolsSupport, LoggingSupport {
           content: [
             TextContent(
               text:
-                  '$commandName failed in ${projectRoot.path}:\n$output\n\n'
-                  'Errors\n$errors',
+                  '$commandDescription failed in ${projectRoot.path}:\n'
+                  '$output\n\nErrors\n$errors',
             ),
           ],
           isError: true,
         );
       }
       if (output.isNotEmpty) {
         outputs.add(
-          TextContent(text: '$commandName in ${projectRoot.path}:\n$output'),
+          TextContent(
+            text: '$commandDescription in ${projectRoot.path}:\n$output',
+          ),
         );
       }
     }
     return CallToolResult(content: outputs);
   }
 
+  static final dartPubTool = Tool(
path: pkgs/dart_tooling_mcp_server/lib/src/mixins/dart_cli.dart
user: login: kenzieschmoll
body: I used `dart pub add` on a flutter project and it succeeded, but my `dart` is the Dart from the Flutter SDK so maybe that is the difference. I filed https://github.com/dart-lang/ai/issues/81 to track work and discuss the best way to determine whether a project is a dart or flutter project.
created_at: 2025-04-22T23:42:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-21T23:58:22Z
body: ## PR Health 


<details>
<summary>
<strong>Changelog Entry</strong> :heavy_check_mark:
</summary>

| Package | Changed Files |
| :--- | :--- |


Changes to files need to be [accounted for](https://github.com/dart-lang/ecosystem/wiki/Changelog) in their respective changelogs.



</details>


author_association: NONE
user: login: kenzieschmoll
created_at: 2025-04-22T23:17:28Z
body: > see comment regarding dart vs flutter "pub" commands.

Responded in the comment above. I'll land as is until we can confirm there is an issue with just always using `dart pub`.
author_association: CONTRIBUTOR
user: login: jakemac53
created_at: 2025-04-22T23:19:05Z
body: > Responded in the comment above. I'll land as is until we can confirm there is an issue with just always using `dart pub`.

See my response, it only works if you don't have flutter deps. Or maybe it works if your `dart` is from the flutter SDK? In my case `dart` is just the Dart SDK version.
author_association: CONTRIBUTOR