[dart_tooling_mcp_server] Add a dart pub tool (#76)

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 2f18a8a..8b3ba0a 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 @@
     }
     registerTool(dartFixTool, _runDartFixTool);
     registerTool(dartFormatTool, _runDartFormatTool);
+    registerTool(dartPubTool, _runDartPubTool);
     return super.initialize(request);
   }
 
@@ -50,6 +51,51 @@
     );
   }
 
+  /// 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 @@
     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 @@
     ),
   );
 }
+
+/// 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 ba93886..0dc4a76 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 @@
   Future<CallToolResult> callToolWithRetry(
     CallToolRequest request, {
     int maxTries = 5,
+    bool expectError = false,
   }) async {
     var tryCount = 0;
     late CallToolResult lastResult;
@@ -143,7 +144,7 @@
     }
     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 843a0e5..3896ef4 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 @@
   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 @@
       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 @@
         ['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);
+        });
+      });
+    });
   });
 }