diff --git a/README.md b/README.md index 1e8c8edc5..6538bfb3c 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,26 @@ very_good packages check licenses --forbidden="unknown" very_good packages check licenses --dependency-type="direct-main,transitive" ``` +### [`very_good mcp`](https://cli.vgv.dev/docs/commands/mcp) + +Start the MCP (Model Context Protocol) server for AI assistant integration. + +> ℹ️ **Experimental** +> This command relies on the [Dart MCP Server](https://docs.flutter.dev/ai/mcp-server). This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk. + +```sh +# Start the MCP server +very_good mcp +``` + +The MCP server exposes Very Good CLI functionality through the Model Context Protocol, allowing AI assistants to interact with the CLI programmatically. This enables automated project creation, testing, and package management through MCP-compatible tools. + +**Available MCP Tools:** +- `create`: Create new Dart/Flutter projects (https://cli.vgv.dev/docs/category/templates) +- `tests`: Run tests with optional coverage and optimization (https://cli.vgv.dev/docs/commands/test) +- `packages_check_licenses`: Check packages for issues and licenses (https://cli.vgv.dev/docs/commands/check_licenses) +- `packages_get`: Get package information and dependencies (https://cli.vgv.dev/docs/commands/get_pkgs) + ### [`very_good --help`](https://cli.vgv.dev/docs/overview) See the complete list of commands and usage information. @@ -194,6 +214,7 @@ Global options: Available commands: create very_good create [arguments] Creates a new very good project in the specified directory. + mcp Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk. packages Command for managing packages. test Run tests in a Dart or Flutter project. update Update Very Good CLI. diff --git a/bin/very_good.dart b/bin/very_good.dart old mode 100644 new mode 100755 diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart index 8d034d6fd..95f324092 100644 --- a/lib/src/command_runner.dart +++ b/lib/src/command_runner.dart @@ -8,6 +8,7 @@ import 'package:pub_updater/pub_updater.dart'; import 'package:universal_io/io.dart'; import 'package:very_good_cli/src/commands/commands.dart'; import 'package:very_good_cli/src/logger_extension.dart'; +import 'package:very_good_cli/src/mcp/mcp.dart'; import 'package:very_good_cli/src/version.dart'; /// The package name. @@ -37,6 +38,7 @@ class VeryGoodCommandRunner extends CompletionCommandRunner { addCommand(TestCommand(logger: _logger)); addCommand(UpdateCommand(logger: _logger, pubUpdater: pubUpdater)); addCommand(DartCommand(logger: _logger)); + addCommand(MCPCommand(logger: _logger)); } /// Standard timeout duration for the CLI. diff --git a/lib/src/mcp/mcp.dart b/lib/src/mcp/mcp.dart new file mode 100644 index 000000000..b401438a4 --- /dev/null +++ b/lib/src/mcp/mcp.dart @@ -0,0 +1,2 @@ +export 'mcp_command.dart'; +export 'mcp_server.dart'; diff --git a/lib/src/mcp/mcp_command.dart b/lib/src/mcp/mcp_command.dart new file mode 100644 index 000000000..950a5944a --- /dev/null +++ b/lib/src/mcp/mcp_command.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:dart_mcp/server.dart'; +import 'package:dart_mcp/stdio.dart'; +import 'package:mason/mason.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:very_good_cli/src/mcp/mcp_server.dart'; + +/// Type definition for a factory that creates a [VeryGoodMCPServer]. +typedef ServerFactory = + MCPServer Function({ + required StreamChannel channel, + required Logger logger, + }); + +/// Factory function to create a [StreamChannel] from input and output streams. +typedef ChannelFactory = StreamChannel Function(); + +// Private default implementation for the channel factory +StreamChannel _defaultChannelFactory() { + return stdioChannel(input: stdin, output: stdout); +} + +/// {@template mcp_command} +/// `very_good mcp` command starts the MCP (Model Context Protocol) server. +/// {@endtemplate} +class MCPCommand extends Command { + /// {@macro mcp_command} + MCPCommand({ + Logger? logger, + ChannelFactory? channelFactory, + ServerFactory? serverFactory, + }) : _logger = logger ?? Logger(), + _channelFactory = channelFactory ?? _defaultChannelFactory, + _serverFactory = serverFactory ?? VeryGoodMCPServer.new; + + /// The [name] of the command. But static. + static const String commandName = 'mcp'; + + @override + String get description => ''' +Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk.'''; + + @override + String get name => commandName; + + final Logger _logger; + + final ChannelFactory _channelFactory; + + final ServerFactory _serverFactory; + + @override + Future run() async { + try { + _logger + ..info('Starting Very Good CLI MCP Server...') + ..info( + 'Server will listen on stdin/stdout for MCP protocol messages', + ); + + // Create a channel from stdin/stdout using the stdio helper + final channel = _channelFactory(); + + // Create and start the MCP server + final server = _serverFactory( + channel: channel, + logger: _logger, + ); + + _logger + ..info('MCP Server started successfully') + ..info('Available tools:') + ..info(''' + - create: Create a very good Dart or Flutter project in seconds based on the provided template. Each template has a corresponding sub-command.''') + ..info(' - test: Run tests in a Dart or Flutter project.') + ..info( + ''' + - packages_get: Install or update Dart/Flutter package dependencies. + Use after creating a project or modifying pubspec.yaml. + Supports recursive installation and package exclusion.''', + ) + ..info( + ''' + - packages_check_licenses: Verify package licenses for compliance and validation in a Dart or Flutter project. + Identifies license types (MIT, BSD, Apache, etc.) for all + dependencies. Use to ensure license compatibility.''', + ); + + // Wait for the server to complete + // (this will block until the connection is closed) + await server.done; + + return ExitCode.success.code; + } on Exception catch (e, stackTrace) { + _logger + ..err('Failed to start MCP server: $e') + ..err('Stack trace: $stackTrace'); + return ExitCode.software.code; + } + } +} diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart new file mode 100644 index 000000000..5476c31a0 --- /dev/null +++ b/lib/src/mcp/mcp_server.dart @@ -0,0 +1,507 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:dart_mcp/server.dart'; +import 'package:mason/mason.dart' hide packageVersion; +import 'package:stream_channel/stream_channel.dart'; +import 'package:very_good_cli/src/command_runner.dart'; +import 'package:very_good_cli/src/version.dart'; + +/// {@template very_good_mcp_server} +/// MCP Server for Very Good CLI. +/// +/// This server exposes Very Good CLI functionality through the +/// Model Context Protocol, allowing AI assistants to interact +/// with the CLI programmatically. +/// {@endtemplate} +final class VeryGoodMCPServer extends MCPServer with ToolsSupport { + /// {@macro very_good_mcp_server} + VeryGoodMCPServer({ + required StreamChannel channel, + Logger? logger, + VeryGoodCommandRunner? commandRunner, + }) : _logger = logger ?? Logger(), + _commandRunner = + commandRunner ?? VeryGoodCommandRunner(logger: logger ?? Logger()), + super.fromStreamChannel( + channel, + implementation: Implementation( + name: 'very_good_cli', + version: packageVersion, + ), + instructions: + 'A Very Good CLI MCP server that provides tools ' + 'for creating and managing Dart/Flutter projects.', + ); + + final Logger _logger; + + final VeryGoodCommandRunner _commandRunner; + + @override + FutureOr initialize(InitializeRequest request) async { + final result = await super.initialize(request); + + _registerTools(); + + return result; + } + + void _registerTools() { + // Create project tool + registerTool( + Tool( + name: 'create', + description: ''' +Create a very good Dart or Flutter project in seconds based on the provided template. Each template has a corresponding sub-command. + ''', + inputSchema: ObjectSchema( + properties: { + 'subcommand': StringSchema( + description: ''' +The available subcommands to provide an specific template, are: +dart_cli - Generate a Very Good Dart CLI application. +dart_package - Generate a Very Good Dart package. +docs_site - Generate a Very Good documentation site. +flame_game - Generate a Very Good Flame game. +flutter_app - Generate a Very Good Flutter application. +flutter_package - Generate a Very Good Flutter package. +flutter_plugin - Generate a Very Good Flutter plugin. +''', + enumValues: [ + 'flame_game', + 'flutter_app', + 'flutter_package', + 'flutter_plugin', + 'dart_cli', + 'dart_package', + 'docs_site', + ], + ), + 'name': StringSchema(description: 'Project name'), + 'description': StringSchema( + description: ''' +The description for this new project. +(defaults to "A Very Good Project created by Very Good CLI.")''', + ), + 'org_name': StringSchema( + description: ''' +The organization for this new project. +(defaults to "com.example.verygoodcore")''', + ), + 'output_directory': StringSchema( + description: + '''The desired output directory when creating a new project.''', + ), + 'application_id': StringSchema( + description: + '''The bundle identifier on iOS or application id on Android. (defaults to .)''', + ), + 'platforms': StringSchema( + description: ''' +Comma-separated platforms. 'Example: "android,ios,web". +The values for platforms are: android, ios, web, macos, linux, and windows. +If is omitted, then all platforms are enabled by default. +Only available for subcommands: flutter_plugin with all values) and flame_game (only android and ios) + ''', + ), + 'publishable': BooleanSchema( + description: ''' +Whether package is intended for publishing (flutter_package, dart_package only)''', + ), + 'executable-name': StringSchema( + description: ''' +CLI custom executable name (dart_cli only)''', + ), + 'template': StringSchema( + description: ''' +The template used to generate this new project. +The values are: +core - Generate a Very Good Flutter application. +wear - Generate a Very Good Flutter Wear OS application. +If is omitted, then core will be selected. +''', + ), + }, + required: ['subcommand', 'name'], + ), + ), + _handleCreate, + ); + + // Test tool + registerTool( + Tool( + name: 'test', + description: 'Run tests in a Dart or Flutter project.', + inputSchema: ObjectSchema( + properties: { + 'dart': BooleanSchema( + description: + '''Whether to run Dart tests. If not specified, Flutter tests will be run if a Flutter project is detected.''', + ), + 'directory': StringSchema(description: 'Project directory'), + 'coverage': BooleanSchema( + description: 'Whether to collect coverage information.', + ), + 'recursive': BooleanSchema( + description: 'Run tests recursively for all nested packages.', + ), + 'optimization': BooleanSchema( + description: ''' +Whether to apply optimizations for test performance. +Automatically disabled when --platform is specified. +Add the `skip_very_good_optimization` tag to specific test files to disable them individually. +(defaults to on)''', + ), + 'concurrency': StringSchema( + description: ''' +The number of concurrent test suites run. +Automatically set to 1 when --platform is specified. +(defaults to "4")''', + ), + 'tags': StringSchema( + description: + '''Run only tests associated with the specified tags.''', + ), + 'exclude_coverage': BooleanSchema( + description: + '''A glob which will be used to exclude files that match from the coverage.''', + ), + 'exclude_tags': StringSchema( + description: + 'Run only tests that do not have the specified tags.', + ), + 'min_coverage': StringSchema( + description: + '''Whether to enforce a minimum coverage percentage.''', + ), + 'test_randomize_ordering_seed': StringSchema( + description: + '''The seed to randomize the execution order of test cases within test files.''', + ), + 'update_goldens': BooleanSchema( + description: ''' +Whether "matchesGoldenFile()" calls within your test methods should update the golden files.''', + ), + 'force_ansi': BooleanSchema( + description: ''' +Whether to force ansi output. If not specified, it will maintain the default behavior based on stdout and stderr.''', + ), + 'dart-define': StringSchema( + description: ''' +Additional key-value pairs that will be available as constants from the String.fromEnvironment, bool.fromEnvironment, int.fromEnvironment, and double.fromEnvironment constructors. +Multiple defines can be passed by repeating "--dart-define" multiple times. +(e.g., foo=bar) +''', + ), + 'dart-define-from-file': StringSchema( + description: ''' +The path of a .json or .env file containing key-value pairs that will be available as environment variables. These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment constructors. +Multiple defines can be passed by repeating "--dart-define-from-file" multiple times. Entries from "--dart-define" with identical keys take precedence over entries from these files.''', + ), + 'platform': StringSchema( + description: ''' +The platform to run tests on. +The available values are: chrome, vm, android, ios. +Only one value can be selected. + ''', + ), + }, + ), + ), + _handleTest, + ); + + // Packages get tool + registerTool( + Tool( + name: 'packages_get', + description: ''' + Install or update a Dart/Flutter package dependencies. + Use after creating a project or modifying pubspec.yaml. + Supports recursive installation and package exclusion.''', + inputSchema: ObjectSchema( + properties: { + 'directory': StringSchema( + description: + 'Target directory path (defaults to current directory). ' + 'Can be absolute or relative path to project root.', + ), + 'recursive': BooleanSchema( + description: + 'Install dependencies for all nested packages recursively. ' + 'Useful for monorepos or projects with multiple packages.', + ), + 'ignore': StringSchema( + description: + 'Comma-separated list of package names to skip. ' + 'Example: "package1,package2". ' + 'Useful to avoid processing problematic packages.', + ), + }, + ), + ), + _handlePackagesGet, + ); + + // Packages check licenses tool + registerTool( + Tool( + name: 'packages_check_licenses', + description: ''' + Verify package licenses for compliance and validation in a Dart or Flutter project. + Identifies license types (MIT, BSD, Apache, etc.) for all + dependencies. Use to ensure license compatibility.''', + inputSchema: ObjectSchema( + properties: { + 'directory': StringSchema( + description: + 'Target directory path (defaults to current directory). ' + 'Path to the project root containing pubspec.yaml.', + ), + 'licenses': BooleanSchema( + description: + 'Verify all package licenses (defaults to true). ' + 'Currently the only supported check type. ' + 'Reports license types for all dependencies.', + ), + }, + ), + ), + _handlePackagesCheck, + ); + } + + List _parseCreate(Map args) { + final subcommand = args['subcommand']! as String; + final name = args['name']! as String; + + final cliArgs = ['create', subcommand, name]; + + if (args['description'] != null) { + cliArgs.addAll(['--desc', args['description']! as String]); + } + if (args['org_name'] != null) { + cliArgs.addAll(['--org-name', args['org_name']! as String]); + } + if (args['output_directory'] != null) { + cliArgs.addAll([ + '-o', + args['output_directory']! as String, + ]); + } + if (args['application_id'] != null) { + cliArgs.addAll(['--application-id', args['application_id']! as String]); + } + if (args['platforms'] != null) { + cliArgs.addAll(['--platforms', args['platforms']! as String]); + } + if (args['publishable'] == true) { + cliArgs.add('--publishable'); + } + if (args['template'] != null) { + cliArgs.addAll(['-t', args['template']! as String]); + } + + return cliArgs; + } + + List _parseTest(Map args) { + final cliArgs = [ + if (args['dart'] == true) 'dart', + 'test', + ]; + + if (args['directory'] != null) { + cliArgs.add(args['directory']! as String); + } + if (args['coverage'] == true) { + cliArgs.add('--coverage'); + } + if (args['recursive'] == true) { + cliArgs.add('-r'); + } + if (args['optimization'] == true) { + cliArgs.add('--optimization'); + } else { + cliArgs.add('--no-optimization'); + } + if (args['concurrency'] != null) { + cliArgs.addAll(['-j', args['concurrency']! as String]); + } + if (args['tags'] != null) { + cliArgs.addAll(['-t', args['tags']! as String]); + } + if (args['exclude_coverage'] == false) { + cliArgs.add('--exclude-coverage'); + } + if (args['exclude_tags'] != null) { + cliArgs.addAll(['-x', args['exclude_tags']! as String]); + } + if (args['min_coverage'] != null) { + cliArgs.addAll(['--min-coverage', args['min_coverage']! as String]); + } + if (args['test_randomize_ordering_seed'] != null) { + cliArgs.addAll([ + '--test-randomize-ordering-seed', + args['test_randomize_ordering_seed']! as String, + ]); + } + if (args['update_goldens'] == true) { + cliArgs.add('--update-goldens'); + } + if (args['force_ansi'] == true) { + cliArgs.add('--force-ansi'); + } + if (args['dart-define'] != null) { + cliArgs.addAll(['--dart-define', args['dart-define']! as String]); + } + if (args['dart-define-from-file'] != null) { + cliArgs.addAll([ + '--dart-define-from-file', + args['dart-define-from-file']! as String, + ]); + } + if (args['platform'] != null) { + cliArgs.addAll(['--platform', args['platform']! as String]); + } + + return cliArgs; + } + + List _parsePackagesGet(Map args) { + final cliArgs = ['packages', 'get']; + + if (args['directory'] != null) { + cliArgs.add(args['directory']! as String); + } + if (args['recursive'] == true) { + cliArgs.add('--recursive'); + } + if (args['ignore'] != null) { + final ignore = (args['ignore']! as String).split(','); + for (final pkg in ignore) { + cliArgs.addAll(['--ignore', pkg.trim()]); + } + } + + return cliArgs; + } + + List _parsePackagesCheck(Map args) { + final cliArgs = ['packages', 'check', 'licenses']; + + if (args['directory'] != null) { + cliArgs.add(args['directory']! as String); + } + + return cliArgs; + } + + Future _handleCreate(CallToolRequest request) async { + final args = request.arguments ?? {}; + final cliArgs = _parseCreate(args); + final exitCode = await _runCommand(cliArgs); + + return CallToolResult( + content: [ + TextContent( + text: exitCode == ExitCode.success.code + ? 'Project created successfully' + : 'Failed to create project', + ), + ], + isError: exitCode != ExitCode.success.code, + ); + } + + Future _handleTest(CallToolRequest request) async { + final args = request.arguments ?? {}; + final cliArgs = _parseTest(args); + final exitCode = await _runCommand(cliArgs); + + return CallToolResult( + content: [ + TextContent( + text: exitCode == ExitCode.success.code + ? 'Tests completed successfully' + : 'Tests failed', + ), + ], + isError: exitCode != ExitCode.success.code, + ); + } + + Future _handlePackagesGet(CallToolRequest request) async { + final args = request.arguments ?? {}; + final cliArgs = _parsePackagesGet(args); + final exitCode = await _runCommand(cliArgs); + + return CallToolResult( + content: [ + TextContent( + text: exitCode == ExitCode.success.code + ? 'Packages retrieved successfully' + : 'Failed to get packages', + ), + ], + isError: exitCode != ExitCode.success.code, + ); + } + + Future _handlePackagesCheck(CallToolRequest request) async { + final args = request.arguments ?? {}; + + // Currently, 'packages check' only has 'licenses' as a subcommand + // Default to checking licenses if not explicitly set to false + final checkLicenses = args['licenses'] as bool? ?? true; + + if (!checkLicenses) { + return CallToolResult( + content: [ + TextContent( + text: + 'No check specified. Currently only "licenses" check is ' + 'supported. Set licenses=true to run license checks.', + ), + ], + isError: true, + ); + } + + final cliArgs = _parsePackagesCheck(args); + final exitCode = await _runCommand(cliArgs); + + return CallToolResult( + content: [ + TextContent( + text: exitCode == ExitCode.success.code + ? 'Package license check completed successfully' + : 'Package license check failed', + ), + ], + isError: exitCode != ExitCode.success.code, + ); + } + + /// Runs CLI commands through the command runner. + /// Commands parse their own arguments using their argParser. + Future _runCommand(List args) async { + try { + _logger.detail('Running: very_good ${args.join(' ')}'); + + final exitCode = await _commandRunner.run(args); + + return exitCode; + } on UsageException catch (e) { + _logger.err('Usage error: ${e.message}'); + return ExitCode.usage.code; + } on Exception catch (e, stackTrace) { + _logger + ..err('Command error: $e') + ..err('Stack trace: $stackTrace'); + return ExitCode.software.code; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index d37f3b6f4..769ce4c6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: cli_completion: ^0.5.1 collection: ^1.19.0 coverage: ^1.15.0 + dart_mcp: ^0.4.0 equatable: ^2.0.5 glob: ^2.1.2 lcov_parser: ^0.1.2 @@ -27,6 +28,7 @@ dependencies: pub_updater: ^0.5.0 pubspec_parse: ^1.3.0 stack_trace: ^1.11.1 + stream_channel: ^2.1.4 universal_io: ^2.2.2 very_good_test_runner: ^0.3.0 yaml: ^3.1.2 diff --git a/site/docs/overview.md b/site/docs/overview.md index bc86c9bac..77a0a51c4 100644 --- a/site/docs/overview.md +++ b/site/docs/overview.md @@ -68,6 +68,15 @@ Run "very_good help" to see global options. You can use `.` as the project name to create the project in the current directory. The project will be named with the name of the current directory. ::: +### `very_good mcp` + +Start the MCP (Model Context Protocol) server for AI assistant integration. + +```sh +# Start the MCP server +very_good mcp +``` + ### `very_good packages get` Get packages in a Dart or Flutter project. @@ -151,6 +160,7 @@ Global options: Available commands: create very_good create [arguments] Creates a new very good project in the specified directory. + mcp Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk. packages Command for managing packages. test Run tests in a Dart or Flutter project. update Update Very Good CLI. diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart index 7e49f8fde..f44b48427 100644 --- a/test/src/command_runner_test.dart +++ b/test/src/command_runner_test.dart @@ -38,6 +38,7 @@ const expectedUsage = [ ' create very_good create [arguments]\n', ''' Creates a new very good project in the specified directory.\n''', ' dart Command for running dart related commands.\n', + ' mcp Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk.\n', ' packages Command for managing packages.\n', ' test Run `flutter test` in a project. (Check very_good dart test for running `dart test` instead.)\n', ' update Update Very Good CLI.\n', diff --git a/test/src/mcp/mcp_command_test.dart b/test/src/mcp/mcp_command_test.dart new file mode 100644 index 000000000..0505b2bd9 --- /dev/null +++ b/test/src/mcp/mcp_command_test.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:dart_mcp/server.dart'; +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/mcp/mcp_command.dart'; +import 'package:very_good_cli/src/mcp/mcp_server.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockChannelFactory extends Mock { + StreamChannel call(); +} + +class _MockServerFactory extends Mock { + MCPServer call({ + required StreamChannel channel, + required Logger logger, + }); +} + +class _FakeStreamChannel extends Fake implements StreamChannel {} + +class _FakeLogger extends Fake implements Logger {} + +void main() { + group('MCPCommand', () { + late Logger logger; + + late StreamChannelController channelController; + + late _MockChannelFactory channelFactory; + late _MockServerFactory serverFactory; + + late MCPServer server; + + setUp(() { + logger = _MockLogger(); + channelController = StreamChannelController(); + channelFactory = _MockChannelFactory(); + serverFactory = _MockServerFactory(); + + server = VeryGoodMCPServer( + channel: channelController.foreign, + logger: logger, + ); + + when(() => channelFactory()).thenAnswer( + (_) => channelController.foreign, + ); + + registerFallbackValue(StackTrace.current); + registerFallbackValue(_FakeStreamChannel()); + registerFallbackValue(_FakeLogger()); + + when( + () => serverFactory( + channel: any(named: 'channel'), + logger: any(named: 'logger'), + ), + ).thenAnswer((_) => server); + + when(() => logger.info(any())).thenAnswer((_) {}); + when(() => logger.err(any())).thenAnswer((_) {}); + }); + + test('should have correct command name', () { + final command = MCPCommand( + logger: logger, + channelFactory: channelFactory.call, + serverFactory: serverFactory.call, + ); + expect(command.name, 'mcp'); + }); + + test('should have correct description', () { + final command = MCPCommand( + logger: logger, + channelFactory: channelFactory.call, + serverFactory: serverFactory.call, + ); + expect( + command.description, + ''' +Start the MCP (Model Context Protocol) server. WARNING: This is an experimental package and may change or become unstable without notice. Use it with caution at your own risk.''', + ); + }); + + test('constructor uses default Logger when not provided', () { + final command = MCPCommand( + channelFactory: channelFactory.call, + serverFactory: serverFactory.call, + ); + + expect(command, isA()); + }); + + test('run() logs success messages and returns success exit code', () async { + final command = MCPCommand( + logger: logger, + channelFactory: channelFactory.call, + serverFactory: serverFactory.call, + ); + + final runFuture = command.run(); + + await Future.delayed(Duration.zero); + + await channelController.local.sink.close(); + + final exitCode = await runFuture; + + expect(exitCode, ExitCode.success.code); + + verify( + () => logger.info('Starting Very Good CLI MCP Server...'), + ).called(1); + verify( + () => logger.info( + 'Server will listen on stdin/stdout for MCP protocol messages', + ), + ).called(1); + verify( + () => logger.info('MCP Server started successfully'), + ).called(1); + verify(() => logger.info('Available tools:')).called(1); + verify( + () => logger.info( + ''' + - create: Create a very good Dart or Flutter project in seconds based on the provided template. Each template has a corresponding sub-command.''', + ), + ).called(1); + verify( + () => logger.info( + ' - test: Run tests in a Dart or Flutter project.', + ), + ).called(1); + verify( + () => logger.info( + ''' + - packages_get: Install or update Dart/Flutter package dependencies. + Use after creating a project or modifying pubspec.yaml. + Supports recursive installation and package exclusion.''', + ), + ).called(1); + verify( + () => logger.info( + ''' + - packages_check_licenses: Verify package licenses for compliance and validation in a Dart or Flutter project. + Identifies license types (MIT, BSD, Apache, etc.) for all + dependencies. Use to ensure license compatibility.''', + ), + ).called(1); + + verify(() => channelFactory()).called(1); + verify( + () => serverFactory( + channel: channelController.foreign, + logger: logger, + ), + ).called(1); + }); + + test('run() uses default channel factory when not provided', () async { + final defaultFactoryChannelController = StreamChannelController(); + + when( + () => serverFactory( + channel: any(named: 'channel'), + logger: logger, + ), + ).thenAnswer((invocation) { + return VeryGoodMCPServer( + channel: defaultFactoryChannelController.foreign, + logger: logger, + ); + }); + + final command = MCPCommand( + logger: logger, + serverFactory: serverFactory.call, + ); + + final runFuture = command.run(); + + await Future.delayed(Duration.zero); + + await defaultFactoryChannelController.local.sink.close(); + + final exitCode = await runFuture; + expect(exitCode, ExitCode.success.code); + + verify( + () => serverFactory( + channel: any(named: 'channel'), + logger: logger, + ), + ).called(1); + + verifyNever(channelFactory.call); + }); + + test( + '''run() logs error and returns software exit code when an exception occurs''', + () async { + final command = MCPCommand( + logger: logger, + channelFactory: channelFactory.call, + serverFactory: serverFactory.call, + ); + + final exception = Exception('Something went wrong'); + when( + () => logger.info('Starting Very Good CLI MCP Server...'), + ).thenThrow(exception); + + final exitCode = await command.run(); + + expect(exitCode, ExitCode.software.code); + + verify( + () => logger.err('Failed to start MCP server: $exception'), + ).called(1); + + verify( + () => logger.err(any(that: startsWith('Stack trace:'))), + ).called(1); + + verifyNever(() => channelFactory()); + verifyNever( + () => serverFactory( + channel: any(named: 'channel'), + logger: any(named: 'logger'), + ), + ); + }, + ); + }); +} diff --git a/test/src/mcp/mcp_server_test.dart b/test/src/mcp/mcp_server_test.dart new file mode 100644 index 000000000..4ef9bee98 --- /dev/null +++ b/test/src/mcp/mcp_server_test.dart @@ -0,0 +1,543 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:args/command_runner.dart'; +import 'package:dart_mcp/server.dart'; +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/command_runner.dart'; +import 'package:very_good_cli/src/mcp/mcp_server.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockVeryGoodCommandRunner extends Mock + implements VeryGoodCommandRunner {} + +int _idCounter = 1; + +String _jsonRpcRequest(String method, Map params) { + final id = _idCounter++; + return jsonEncode({ + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + 'id': id, + }); +} + +// Helper function for params, as extension type is Map +// This helper casts the dynamic (which is really an extension type) +// to the expected Map type. +Map _params(dynamic params) => params as Map; + +void main() { + group('VeryGoodMCPServer', () { + late Logger mockLogger; + late VeryGoodCommandRunner mockCommandRunner; + late StreamChannelController channelController; + // ignore: unused_local_variable Server is not used directly, but needed to keep the channel open + late VeryGoodMCPServer server; + late Stream> serverResponses; + + setUpAll(() { + _idCounter = 1; + }); + + Future> sendRequest( + String method, [ + Map params = const {}, + ]) async { + final completer = Completer>(); + // Get the ID that will be used for this request + final requestId = _idCounter; + + final subscription = serverResponses.listen((response) { + if (response['id'] == requestId) { + completer.complete(response); + } + }); + + // Send the request (which increments the counter) + channelController.local.sink.add( + _jsonRpcRequest(method, params as Map), + ); + + final response = await completer.future; + await subscription.cancel(); + return response; + } + + setUp(() async { + mockLogger = _MockLogger(); + mockCommandRunner = _MockVeryGoodCommandRunner(); + channelController = StreamChannelController(); + server = VeryGoodMCPServer( + channel: channelController.foreign, + logger: mockLogger, + commandRunner: mockCommandRunner, + ); + + serverResponses = channelController.local.stream + .map((event) => jsonDecode(event) as Map) + .asBroadcastStream(); + + registerFallbackValue( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test', version: '1.0.0'), + ), + ); + registerFallbackValue( + CallToolRequest( + name: 'dummyTool', + arguments: const {}, + ), + ); + + when( + () => mockCommandRunner.run(any()), + ).thenAnswer((_) async => ExitCode.success.code); + when(() => mockLogger.info(any())).thenAnswer((_) {}); + when(() => mockLogger.err(any())).thenAnswer((_) {}); + when(() => mockLogger.detail(any())).thenAnswer((_) {}); + + // This is the handshake that + // MUST happen before any other requests, to fix the timeout. + final initResponse = await sendRequest( + InitializeRequest.methodName, + // Use the helper to cast the extension type + _params( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '0.1.0'), + ), + ), + ); + + expect( + initResponse['error'], + isNull, + reason: 'Server initialization failed', + ); + }); + + test('constructor uses default logger and runner if not provided', () { + final defaultFactoryChannelController = StreamChannelController(); + + final defaultServer = VeryGoodMCPServer( + channel: defaultFactoryChannelController.foreign, + ); + expect(defaultServer, isA()); + }); + + test('initialize (via tools/list) registers all 4 tools', () async { + // The server is ALREADY initialized in setUp. + // We can just send a 'tools/list' request directly. + final response = await sendRequest(ListToolsRequest.methodName); + + expect(response['error'], isNull); + expect(response['result'], isA>()); + + final result = ListToolsResult.fromMap( + response['result'] as Map, + ); + expect(result.tools.length, 4); + expect( + result.tools.map((t) => t.name), + containsAll([ + 'create', + 'test', + 'packages_get', + 'packages_check_licenses', + ]), + ); + }); + + group('Tool: create', () { + test('handles basic case', () async { + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: {'subcommand': 'flutter_app', 'name': 'my_app'}, + ), + ), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isFalse); + expect( + (result.content.first as TextContent).text, + 'Project created successfully', + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, ['create', 'flutter_app', 'my_app']); + }); + + test('handles all arguments', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: { + 'subcommand': 'flutter_app', + 'name': 'my_app', + 'description': 'my_desc', + 'org_name': 'com.test', + 'output_directory': 'my_dir', + 'application_id': 'com.test.my_app', + 'platforms': 'ios,web', + 'publishable': true, + 'template': 'wear', + }, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, [ + 'create', + 'flutter_app', + 'my_app', + '--desc', + 'my_desc', + '--org-name', + 'com.test', + '-o', + 'my_dir', + '--application-id', + 'com.test.my_app', + '--platforms', + 'ios,web', + '--publishable', + '-t', + 'wear', + ]); + }); + + test('handles command runner failure', () async { + when( + () => mockCommandRunner.run(any()), + ).thenAnswer((_) async => ExitCode.software.code); + + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: {'subcommand': 'flutter_app', 'name': 'my_app'}, + ), + ), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + 'Failed to create project', + ); + }); + + test('handles argument parsing exception', () async { + // Missing 'name' argument + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: {'subcommand': 'flutter_app'}, + ), + ), + ); + + // The handler's try/catch will catch this + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + contains('Required property "name" is missing at path #root'), + ); + }); + }); + + group('Tool: test', () { + test('handles basic case with --no-optimization', () async { + await sendRequest( + CallToolRequest.methodName, + _params(CallToolRequest(name: 'test', arguments: {})), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, ['test', '--no-optimization']); + }); + + test('handles all arguments', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'test', + arguments: { + 'dart': true, + 'directory': 'my_dir', + 'coverage': true, + 'recursive': true, + 'optimization': true, + 'concurrency': '8', + 'tags': 'a,b', + 'exclude_coverage': false, + 'exclude_tags': 'c,d', + 'min_coverage': '90', + 'test_randomize_ordering_seed': '123', + 'update_goldens': true, + 'force_ansi': true, + 'dart-define': 'foo=bar', + 'dart-define-from-file': 'my_file.json', + 'platform': 'chrome', + }, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, [ + 'dart', + 'test', + 'my_dir', + '--coverage', + '-r', + '--optimization', + '-j', + '8', + '-t', + 'a,b', + '--exclude-coverage', + '-x', + 'c,d', + '--min-coverage', + '90', + '--test-randomize-ordering-seed', + '123', + '--update-goldens', + '--force-ansi', + '--dart-define', + 'foo=bar', + '--dart-define-from-file', + 'my_file.json', + '--platform', + 'chrome', + ]); + }); + + test('handles command failure', () async { + when( + () => mockCommandRunner.run(any()), + ).thenAnswer((_) async => ExitCode.software.code); + final response = await sendRequest( + CallToolRequest.methodName, + _params(CallToolRequest(name: 'test', arguments: {})), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + 'Tests failed', + ); + }); + }); + + group('Tool: packages_get', () { + test('handles basic case', () async { + await sendRequest( + CallToolRequest.methodName, + _params(CallToolRequest(name: 'packages_get', arguments: {})), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, ['packages', 'get']); + }); + + test('handles all arguments (with split "ignore")', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'packages_get', + arguments: { + 'directory': 'my_dir', + 'recursive': true, + 'ignore': 'pkg1, pkg2', + }, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, [ + 'packages', + 'get', + 'my_dir', + '--recursive', + '--ignore', + 'pkg1', + '--ignore', + 'pkg2', + ]); + }); + }); + + group('Tool: packages_check_licenses', () { + test('handles basic case (licenses=true)', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'packages_check_licenses', + arguments: {'licenses': true, 'directory': 'my_dir'}, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, ['packages', 'check', 'licenses', 'my_dir']); + }); + + test('defaults to licenses=true if not provided', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'packages_check_licenses', + arguments: {'directory': 'my_dir'}, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect(capturedArgs, ['packages', 'check', 'licenses', 'my_dir']); + }); + + test('returns error if licenses=false', () async { + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'packages_check_licenses', + arguments: {'licenses': false}, + ), + ), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + contains('No check specified'), + ); + verifyNever(() => mockCommandRunner.run(any())); + }); + }); + + group('_runCommand error handling', () { + test('handles UsageException', () async { + final exception = UsageException('bad usage', 'usage string'); + when(() => mockCommandRunner.run(any())).thenThrow(exception); + + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: {'subcommand': 'flutter_app', 'name': 'my_app'}, + ), + ), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + 'Failed to create project', + ); + + verify(() => mockLogger.err('Usage error: bad usage')).called(1); + }); + + test('handles general Exception', () async { + final exception = Exception('big bad'); + when(() => mockCommandRunner.run(any())).thenThrow(exception); + + final response = await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: {'subcommand': 'flutter_app', 'name': 'my_app'}, + ), + ), + ); + + expect(response['error'], isNull); + final result = CallToolResult.fromMap( + response['result'] as Map, + ); + + expect(result.isError, isTrue); + expect( + (result.content.first as TextContent).text, + 'Failed to create project', + ); + + verify( + () => mockLogger.err('Command error: Exception: big bad'), + ).called(1); + verify( + () => mockLogger.err(any(that: startsWith('Stack trace:'))), + ).called(1); + }); + }); + }); +}