diff --git a/pkgs/dart_mcp_server/CHANGELOG.md b/pkgs/dart_mcp_server/CHANGELOG.md index f153498b..3802d734 100644 --- a/pkgs/dart_mcp_server/CHANGELOG.md +++ b/pkgs/dart_mcp_server/CHANGELOG.md @@ -16,6 +16,8 @@ * Stop reporting non-zero exit codes from command line tools as tool errors. * Add descriptions for pub tools, add support for `pub deps` and `pub outdated`. * Fix a bug in hot_reload ([#290](https://github.com/dart-lang/ai/issues/290)). +* Add the `list_devices`, `launch_app`, `get_app_logs`, and `list_running_apps` + tools for running Flutter apps. # 0.1.0 (Dart SDK 3.9.0) diff --git a/pkgs/dart_mcp_server/README.md b/pkgs/dart_mcp_server/README.md index e7edf7fb..6759d682 100644 --- a/pkgs/dart_mcp_server/README.md +++ b/pkgs/dart_mcp_server/README.md @@ -135,25 +135,30 @@ For more information, see the official VS Code documentation for | Tool Name | Title | Description | | --- | --- | --- | +| `add_roots` | Add roots | Adds one or more project roots. Tools are only allowed to run under these roots, so you must call this function before passing any roots to any other tools. | +| `analyze_files` | Analyze projects | Analyzes specific paths, or the entire project, for errors. | | `connect_dart_tooling_daemon` | Connect to DTD | Connects to the Dart Tooling Daemon. You should get the uri either from available tools or the user, do not just make up a random URI to pass. When asking the user for the uri, you should suggest the "Copy DTD Uri to clipboard" action. When reconnecting after losing a connection, always request a new uri first. | +| `create_project` | Create project | Creates a new Dart or Flutter project. | +| `dart_fix` | Dart fix | Runs `dart fix --apply` for the given project roots. | +| `dart_format` | Dart format | Runs `dart format .` for the given project roots. | +| `flutter_driver` | Flutter Driver | Run a flutter driver command | +| `get_active_location` | Get Active Editor Location | Retrieves the current active location (e.g., cursor position) in the connected editor. Requires "connect_dart_tooling_daemon" to be successfully called first. | +| `get_app_logs` | | Returns the collected logs for a given flutter run process id. Can only retrieve logs started by the launch_app tool. | | `get_runtime_errors` | Get runtime errors | Retrieves the most recent runtime errors that have occurred in the active Dart or Flutter application. Requires "connect_dart_tooling_daemon" to be successfully called first. | -| `hot_reload` | Hot reload | Performs a hot reload of the active Flutter application. This is to apply the latest code changes to the running application. Requires "connect_dart_tooling_daemon" to be successfully called first. | -| `get_widget_tree` | Get widget tree | Retrieves the widget tree from the active Flutter application. Requires "connect_dart_tooling_daemon" to be successfully called first. | | `get_selected_widget` | Get selected widget | Retrieves the selected widget from the active Flutter application. Requires "connect_dart_tooling_daemon" to be successfully called first. | -| `set_widget_selection_mode` | Set Widget Selection Mode | Enables or disables widget selection mode in the active Flutter application. Requires "connect_dart_tooling_daemon" to be successfully called first. This is not necessary when using flutter driver, only use it when you want the user to select a widget. | -| `get_active_location` | Get Active Editor Location | Retrieves the current active location (e.g., cursor position) in the connected editor. Requires "connect_dart_tooling_daemon" to be successfully called first. | -| `flutter_driver` | Flutter Driver | Run a flutter driver command | +| `get_widget_tree` | Get widget tree | Retrieves the widget tree from the active Flutter application. Requires "connect_dart_tooling_daemon" to be successfully called first. | +| `hot_reload` | Hot reload | Performs a hot reload of the active Flutter application. This is to apply the latest code changes to the running application. Requires "connect_dart_tooling_daemon" to be successfully called first. | +| `hover` | Hover information | Get hover information at a given cursor position in a file. This can include documentation, type information, etc for the text at that position. | +| `launch_app` | | Launches a Flutter application and returns its DTD URI. | +| `list_devices` | | Lists available Flutter devices. | +| `list_running_apps` | | Returns the list of running app process IDs and associated DTD URIs for apps started by the launch_app tool. | +| `pub` | pub | Runs a pub command for the given project roots, like `dart pub get` or `flutter pub add`. | | `pub_dev_search` | pub.dev search | Searches pub.dev for packages relevant to a given search query. The response will describe each result with its download count, package description, topics, license, and publisher. | | `remove_roots` | Remove roots | Removes one or more project roots previously added via the add_roots tool. | -| `add_roots` | Add roots | Adds one or more project roots. Tools are only allowed to run under these roots, so you must call this function before passing any roots to any other tools. | -| `dart_fix` | Dart fix | Runs `dart fix --apply` for the given project roots. | -| `dart_format` | Dart format | Runs `dart format .` for the given project roots. | -| `run_tests` | Run tests | Run Dart or Flutter tests with an agent centric UX. ALWAYS use instead of `dart test` or `flutter test` shell commands. | -| `create_project` | Create project | Creates a new Dart or Flutter project. | -| `pub` | pub | Runs a pub command for the given project roots, like `dart pub get` or `flutter pub add`. | -| `analyze_files` | Analyze projects | Analyzes specific paths, or the entire project, for errors. | | `resolve_workspace_symbol` | Project search | Look up a symbol or symbols in all workspaces by name. Can be used to validate that a symbol exists or discover small spelling mistakes, since the search is fuzzy. | +| `run_tests` | Run tests | Run Dart or Flutter tests with an agent centric UX. ALWAYS use instead of `dart test` or `flutter test` shell commands. | +| `set_widget_selection_mode` | Set Widget Selection Mode | Enables or disables widget selection mode in the active Flutter application. Requires "connect_dart_tooling_daemon" to be successfully called first. This is not necessary when using flutter driver, only use it when you want the user to select a widget. | | `signature_help` | Signature help | Get signature help for an API being used at a given cursor position in a file. | -| `hover` | Hover information | Get hover information at a given cursor position in a file. This can include documentation, type information, etc for the text at that position. | +| `stop_app` | | Kills a running Flutter process started by the launch_app tool. | diff --git a/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart b/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart index b9843e14..52d91591 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/analyzer.dart @@ -17,6 +17,7 @@ import '../utils/analytics.dart'; import '../utils/cli_utils.dart'; import '../utils/constants.dart'; import '../utils/file_system.dart'; +import '../utils/process_manager.dart'; import '../utils/sdk.dart'; /// Mix this in to any MCPServer to add support for analyzing Dart projects. @@ -25,7 +26,7 @@ import '../utils/sdk.dart'; /// mixins applied. base mixin DartAnalyzerSupport on ToolsSupport, LoggingSupport, RootsTrackingSupport, FileSystemSupport - implements SdkSupport { + implements SdkSupport, ProcessManagerSupport { /// The LSP server connection for the analysis server. Peer? _lspConnection; @@ -94,7 +95,8 @@ base mixin DartAnalyzerSupport /// /// On failure, returns a reason for the failure. Future _initializeAnalyzerLspServer() async { - final lspServer = await Process.start(sdk.dartExecutablePath, [ + final lspServer = await processManager.start([ + sdk.dartExecutablePath, 'language-server', // Required even though it is documented as the default. // https://github.com/dart-lang/sdk/issues/60574 diff --git a/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart new file mode 100644 index 00000000..afd8d6f2 --- /dev/null +++ b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart @@ -0,0 +1,466 @@ +// 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 mixin that provides tools for launching and managing Flutter applications. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dart_mcp/server.dart'; + +import '../utils/process_manager.dart'; +import '../utils/sdk.dart'; + +class _RunningApp { + final Process process; + final List logs = []; + String? dtdUri; + + _RunningApp(this.process); +} + +/// A mixin that provides tools for launching and managing Flutter applications. +/// +/// This mixin registers tools for launching, stopping, and listing Flutter +/// applications, as well as listing available devices and retrieving +/// application logs. It manages the lifecycle of Flutter processes that it +/// launches. +base mixin FlutterLauncherSupport + on ToolsSupport, LoggingSupport, RootsTrackingSupport + implements ProcessManagerSupport, SdkSupport { + final Map _runningApps = {}; + + @override + FutureOr initialize(InitializeRequest request) { + registerTool(launchAppTool, _launchApp); + registerTool(stopAppTool, _stopApp); + registerTool(listDevicesTool, _listDevices); + registerTool(getAppLogsTool, _getAppLogs); + registerTool(listRunningAppsTool, _listRunningApps); + return super.initialize(request); + } + + /// A tool to launch a Flutter application. + final launchAppTool = Tool( + name: 'launch_app', + description: 'Launches a Flutter application and returns its DTD URI.', + inputSchema: Schema.object( + properties: { + 'root': Schema.string( + description: 'The root directory of the Flutter project.', + ), + 'target': Schema.string( + description: + 'The main entry point file of the application. Defaults to "lib/main.dart".', + ), + 'device': Schema.string( + description: + 'The device ID to launch the application on. To get a list of ' + 'available devices to present as choices, use the ' + 'list_devices tool.', + ), + }, + required: ['root', 'device'], + ), + outputSchema: Schema.object( + properties: { + 'dtdUri': Schema.string( + description: 'The DTD URI of the launched Flutter application.', + ), + 'pid': Schema.int( + description: 'The process ID of the launched Flutter application.', + ), + }, + required: ['dtdUri', 'pid'], + ), + ); + + Future _launchApp(CallToolRequest request) async { + final root = request.arguments!['root'] as String; + final target = request.arguments!['target'] as String?; + final device = request.arguments!['device'] as String; + final completer = Completer<({Uri dtdUri, int pid})>(); + + log( + LoggingLevel.debug, + 'Launching app with root: $root, target: $target, device: $device', + ); + + Process? process; + try { + process = await processManager.start( + [ + sdk.flutterExecutablePath, + 'run', + '--print-dtd', + '--device-id', + device, + if (target != null) '--target', + if (target != null) target, + ], + workingDirectory: root, + mode: ProcessStartMode.normal, + ); + _runningApps[process.pid] = _RunningApp(process); + log( + LoggingLevel.info, + 'Launched Flutter application with PID: ${process.pid}', + ); + + final stdoutDone = Completer(); + final stderrDone = Completer(); + + late StreamSubscription stdoutSubscription; + late StreamSubscription stderrSubscription; + final dtdUriRegex = RegExp( + r'The Dart Tooling Daemon is available at: (ws://.+:\d+/\S+=)', + ); + + void checkForDtdUri(String line) { + final match = dtdUriRegex.firstMatch(line); + if (match != null && !completer.isCompleted) { + final dtdUri = Uri.parse(match.group(1)!); + log(LoggingLevel.debug, 'Found DTD URI: $dtdUri'); + completer.complete((dtdUri: dtdUri, pid: process!.pid)); + } + } + + stdoutSubscription = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + log( + LoggingLevel.debug, + '[flutter stdout ${process!.pid}]: $line', + ); + _runningApps[process.pid]?.logs.add('[stdout] $line'); + checkForDtdUri(line); + }, + onDone: stdoutDone.complete, + onError: stdoutDone.completeError, + ); + + stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + log( + LoggingLevel.warning, + '[flutter stderr ${process!.pid}]: $line', + ); + _runningApps[process.pid]?.logs.add('[stderr] $line'); + checkForDtdUri(line); + }, + onDone: stderrDone.complete, + onError: stderrDone.completeError, + ); + + unawaited( + process.exitCode.then((exitCode) async { + // Wait for both streams to finish processing before potentially + // completing the completer with an error. + await Future.wait([stdoutDone.future, stderrDone.future]); + + log( + LoggingLevel.info, + 'Flutter application ${process!.pid} exited with code $exitCode.', + ); + _runningApps.remove(process.pid); + if (!completer.isCompleted) { + completer.completeError( + 'Flutter application exited with code $exitCode before the DTD ' + 'URI was found.', + ); + } + // Cancel subscriptions after all processing is done. + await stdoutSubscription.cancel(); + await stderrSubscription.cancel(); + }), + ); + + final result = await completer.future.timeout( + const Duration(seconds: 90), + ); + _runningApps[result.pid]?.dtdUri = result.dtdUri.toString(); + + return CallToolResult( + content: [ + TextContent( + text: + 'Flutter application launched successfully with PID ' + '${result.pid} with the DTD URI ${result.dtdUri}', + ), + ], + structuredContent: { + 'dtdUri': result.dtdUri.toString(), + 'pid': result.pid, + }, + ); + } catch (e, s) { + log(LoggingLevel.error, 'Error launching Flutter application: $e\n$s'); + if (process != null) { + process.kill(); + // The exitCode handler will perform the rest of the cleanup. + } + return CallToolResult( + isError: true, + content: [ + TextContent(text: 'Failed to launch Flutter application: $e'), + ], + ); + } + } + + /// A tool to stop a running Flutter application. + final stopAppTool = Tool( + name: 'stop_app', + description: + 'Kills a running Flutter process started by the launch_app tool.', + inputSchema: Schema.object( + properties: { + 'pid': Schema.int( + description: 'The process ID of the process to kill.', + ), + }, + required: ['pid'], + ), + outputSchema: Schema.object( + properties: { + 'success': Schema.bool( + description: 'Whether the process was killed successfully.', + ), + }, + required: ['success'], + ), + ); + + Future _stopApp(CallToolRequest request) async { + final pid = request.arguments!['pid'] as int; + log(LoggingLevel.info, 'Attempting to stop application with PID: $pid'); + final app = _runningApps[pid]; + + if (app == null) { + log(LoggingLevel.error, 'Application with PID $pid not found.'); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Application with PID $pid not found.')], + ); + } + + final success = processManager.killPid(pid); + if (success) { + log( + LoggingLevel.info, + 'Successfully sent kill signal to application $pid.', + ); + } else { + log( + LoggingLevel.warning, + 'Failed to send kill signal to application $pid.', + ); + } + + return CallToolResult( + content: [ + TextContent( + text: + 'Application with PID $pid ' + '${success ? 'was stopped' : 'was unable to be stopped'}.', + ), + ], + isError: !success, + structuredContent: {'success': success}, + ); + } + + /// A tool to list available Flutter devices. + final listDevicesTool = Tool( + name: 'list_devices', + description: 'Lists available Flutter devices.', + inputSchema: Schema.object(), + outputSchema: Schema.object( + properties: { + 'devices': Schema.list( + description: 'A list of available device IDs.', + items: Schema.string(), + ), + }, + required: ['devices'], + ), + ); + + Future _listDevices(CallToolRequest request) async { + try { + log(LoggingLevel.debug, 'Listing flutter devices.'); + final result = await processManager.run([ + sdk.flutterExecutablePath, + 'devices', + '--machine', + ]); + + if (result.exitCode != 0) { + log( + LoggingLevel.error, + 'Flutter devices command failed with exit code ${result.exitCode}. ' + 'Stderr: ${result.stderr}', + ); + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Failed to list Flutter devices: ${result.stderr}', + ), + ], + ); + } + + final stdout = result.stdout as String; + if (stdout.isEmpty) { + log(LoggingLevel.debug, 'No devices found.'); + return CallToolResult( + content: [TextContent(text: 'No devices found.')], + structuredContent: {'devices': []}, + ); + } + + final devices = (jsonDecode(stdout) as List) + .cast>() + .map((device) => device['id'] as String) + .toList(); + log(LoggingLevel.debug, 'Found devices: $devices'); + + return CallToolResult( + content: [TextContent(text: 'Found devices: ${devices.join(', ')}')], + structuredContent: {'devices': devices}, + ); + } catch (e, s) { + log(LoggingLevel.error, 'Error listing Flutter devices: $e\n$s'); + return CallToolResult( + isError: true, + content: [TextContent(text: 'Failed to list Flutter devices: $e')], + ); + } + } + + /// A tool to get the logs for a running Flutter application. + final getAppLogsTool = Tool( + name: 'get_app_logs', + description: + 'Returns the collected logs for a given flutter run process ' + 'id. Can only retrieve logs started by the launch_app tool.', + inputSchema: Schema.object( + properties: { + 'pid': Schema.int( + description: + 'The process ID of the flutter run process running the ' + 'application.', + ), + }, + required: ['pid'], + ), + outputSchema: Schema.object( + properties: { + 'logs': Schema.list( + description: 'The collected logs for the process.', + items: Schema.string(), + ), + }, + required: ['logs'], + ), + ); + + Future _getAppLogs(CallToolRequest request) async { + final pid = request.arguments!['pid'] as int; + log(LoggingLevel.info, 'Getting logs for application with PID: $pid'); + final logs = _runningApps[pid]?.logs; + + if (logs == null) { + log( + LoggingLevel.error, + 'Application with PID $pid not found or has no logs.', + ); + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Application with PID $pid not found or has no logs.', + ), + ], + ); + } + + return CallToolResult( + content: [TextContent(text: logs.join('\n'))], + structuredContent: {'logs': logs}, + ); + } + + /// A tool to list all running Flutter applications. + final listRunningAppsTool = Tool( + name: 'list_running_apps', + description: + 'Returns the list of running app process IDs and associated ' + 'DTD URIs for apps started by the launch_app tool.', + inputSchema: Schema.object(), + outputSchema: Schema.object( + properties: { + 'apps': Schema.list( + description: + 'A list of running applications started by the ' + 'launch_app tool.', + items: Schema.object( + properties: { + 'pid': Schema.int( + description: 'The process ID of the application.', + ), + 'dtdUri': Schema.string( + description: 'The DTD URI of the application.', + ), + }, + required: ['pid', 'dtdUri'], + ), + ), + }, + required: ['apps'], + ), + ); + + Future _listRunningApps(CallToolRequest request) async { + final apps = _runningApps.entries + .where((entry) => entry.value.dtdUri != null) + .map((entry) { + return {'pid': entry.key, 'dtdUri': entry.value.dtdUri!}; + }) + .toList(); + + return CallToolResult( + content: [ + TextContent( + text: + 'Found ${apps.length} running application' + '${apps.length == 1 ? '' : 's'}.\n' + '${apps.map((e) { + return 'PID: ${e['pid']}, DTD URI: ${e['dtdUri']}'; + }).toList().join('\n')}', + ), + ], + structuredContent: {'apps': apps}, + ); + } + + @override + Future shutdown() { + log(LoggingLevel.info, 'Shutting down server, killing all processes.'); + for (final pid in _runningApps.keys) { + log(LoggingLevel.debug, 'Killing process $pid.'); + processManager.killPid(pid); + } + _runningApps.clear(); + return super.shutdown(); + } +} diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart index 249905a4..2c975375 100644 --- a/pkgs/dart_mcp_server/lib/src/server.dart +++ b/pkgs/dart_mcp_server/lib/src/server.dart @@ -19,6 +19,7 @@ import 'arg_parser.dart'; import 'mixins/analyzer.dart'; import 'mixins/dash_cli.dart'; import 'mixins/dtd.dart'; +import 'mixins/flutter_launcher.dart'; import 'mixins/prompts.dart'; import 'mixins/pub.dart'; import 'mixins/pub_dev_search.dart'; @@ -41,6 +42,7 @@ final class DartMCPServer extends MCPServer PubSupport, PubDevSupport, DartToolingDaemonSupport, + FlutterLauncherSupport, PromptsSupport, DashPrompts implements @@ -149,7 +151,7 @@ final class DartMCPServer extends MCPServer static final argParser = createArgParser(); @override - final LocalProcessManager processManager; + final ProcessManager processManager; @override final FileSystem fileSystem; diff --git a/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart b/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart new file mode 100644 index 00000000..7ec8b5e7 --- /dev/null +++ b/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart @@ -0,0 +1,346 @@ +// 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:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:dart_mcp/client.dart'; +import 'package:dart_mcp_server/src/server.dart'; +import 'package:dart_mcp_server/src/utils/sdk.dart'; +import 'package:file/memory.dart'; +import 'package:process/process.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart' as test; + +void main() { + test.group('DartMCPServer', () { + late MemoryFileSystem fileSystem; + const projectRoot = '/project'; + + Future<({DartMCPServer server, ServerConnection client})> + createServerAndClient({ + required ProcessManager processManager, + required MemoryFileSystem fileSystem, + }) async { + final channel = StreamChannelController(); + final server = DartMCPServer( + channel.local, + sdk: Sdk( + flutterSdkPath: Platform.isWindows + ? r'C:\path\to\flutter\sdk' + : '/path/to/flutter/sdk', + dartSdkPath: Platform.isWindows + ? r'C:\path\to\flutter\sdk\bin\cache\dart-sdk' + : '/path/to/flutter/sdk/bin/cache/dart-sdk', + ), + processManager: processManager, + fileSystem: fileSystem, + ); + final client = ServerConnection.fromStreamChannel(channel.foreign); + return (server: server, client: client); + } + + test.setUp(() { + fileSystem = MemoryFileSystem(); + fileSystem.directory(projectRoot).createSync(recursive: true); + }); + + test.test('launch_app tool returns DTD URI and PID on success', () async { + final dtdUri = 'ws://127.0.0.1:12345/abcdefg='; + final processPid = 54321; + final mockProcessManager = MockProcessManager(); + mockProcessManager.addCommand( + Command( + [ + Platform.isWindows + ? r'C:\path\to\flutter\sdk\bin\cache\dart-sdk\bin\dart.exe' + : '/path/to/flutter/sdk/bin/cache/dart-sdk/bin/dart', + 'language-server', + '--protocol', + 'lsp', + ], + stdout: + '''Content-Length: 145\r\n\r\n{"jsonrpc":"2.0","id":0,"result":{"capabilities":{"workspace":{"workspaceFolders":{"supported":true,"changeNotifications":true}},"workspaceSymbolProvider":true}}}''', + ), + ); + mockProcessManager.addCommand( + Command( + [ + Platform.isWindows + ? r'C:\path\to\flutter\sdk\bin\flutter.bat' + : '/path/to/flutter/sdk/bin/flutter', + 'run', + '--print-dtd', + '--device-id', + 'test-device', + ], + stdout: 'The Dart Tooling Daemon is available at: $dtdUri\n', + pid: processPid, + ), + ); + final serverAndClient = await createServerAndClient( + processManager: mockProcessManager, + fileSystem: fileSystem, + ); + final server = serverAndClient.server; + final client = serverAndClient.client; + + // Initialize + final initResult = await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + test.expect(initResult.serverInfo.name, 'dart and flutter tooling'); + client.notifyInitialized(); + + // Call the tool + final result = await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': projectRoot, 'device': 'test-device'}, + ), + ); + test.expect(result.content, [ + Content.text( + text: + 'Flutter application launched successfully with PID 54321 with the DTD URI ws://127.0.0.1:12345/abcdefg=', + ), + ]); + test.expect(result.isError, test.isNot(true)); + test.expect(result.structuredContent, { + 'dtdUri': dtdUri, + 'pid': processPid, + }); + await server.shutdown(); + await client.shutdown(); + }); + + test.test( + 'launch_app tool returns DTD URI and PID on success from stderr', + () async { + final dtdUri = 'ws://127.0.0.1:12345/abcdefg='; + final processPid = 54321; + final mockProcessManager = MockProcessManager(); + mockProcessManager.addCommand( + Command( + [ + Platform.isWindows + ? r'C:\path\to\flutter\sdk\bin\cache\dart-sdk\bin\dart.exe' + : '/path/to/flutter/sdk/bin/cache/dart-sdk/bin/dart', + 'language-server', + '--protocol', + 'lsp', + ], + stdout: + '''Content-Length: 145\r\n\r\n{"jsonrpc":"2.0","id":0,"result":{"capabilities":{"workspace":{"workspaceFolders":{"supported":true,"changeNotifications":true}},"workspaceSymbolProvider":true}}}''', + ), + ); + mockProcessManager.addCommand( + Command( + [ + Platform.isWindows + ? r'C:\path\to\flutter\sdk\bin\flutter.bat' + : '/path/to/flutter/sdk/bin/flutter', + 'run', + '--print-dtd', + '--device-id', + 'test-device', + ], + stderr: 'The Dart Tooling Daemon is available at: $dtdUri\n', + pid: processPid, + ), + ); + final serverAndClient = await createServerAndClient( + processManager: mockProcessManager, + fileSystem: fileSystem, + ); + final server = serverAndClient.server; + final client = serverAndClient.client; + + // Initialize + final initResult = await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + test.expect(initResult.serverInfo.name, 'dart and flutter tooling'); + client.notifyInitialized(); + + // Call the tool + final result = await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': projectRoot, 'device': 'test-device'}, + ), + ); + + test.expect(result.content, [ + Content.text( + text: + 'Flutter application launched successfully with PID 54321 with the DTD URI ws://127.0.0.1:12345/abcdefg=', + ), + ]); + test.expect(result.isError, test.isNot(true)); + test.expect(result.structuredContent, { + 'dtdUri': dtdUri, + 'pid': processPid, + }); + await server.shutdown(); + await client.shutdown(); + }, + ); + }); +} + +class Command { + final List command; + final String? stdout; + final String? stderr; + final Future? exitCode; + final int pid; + + Command( + this.command, { + this.stdout, + this.stderr, + this.exitCode, + this.pid = 12345, + }); +} + +class MockProcessManager implements ProcessManager { + final List _commands = []; + final List> commands = []; + final Map runningProcesses = {}; + bool shouldThrowOnStart = false; + bool killResult = true; + final killedPids = []; + int _pidCounter = 12345; + + void addCommand(Command command) { + _commands.add(command); + } + + Command _findCommand(List command) { + for (final cmd in _commands) { + if (const ListEquality().equals(cmd.command, command)) { + return cmd; + } + } + throw Exception( + 'Command not mocked: $command. Mocked commands:\n${_commands.join('\n')}', + ); + } + + @override + Future start( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + if (shouldThrowOnStart) { + throw Exception('Failed to start process'); + } + commands.add(command); + final mockCommand = _findCommand(command); + final pid = mockCommand.pid == 12345 ? _pidCounter++ : mockCommand.pid; + final process = MockProcess( + stdout: Stream.value(utf8.encode(mockCommand.stdout ?? '')), + stderr: Stream.value(utf8.encode(mockCommand.stderr ?? '')), + pid: pid, + exitCodeFuture: mockCommand.exitCode, + ); + runningProcesses[pid] = process; + return process; + } + + @override + bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) { + killedPids.add(pid); + runningProcesses[pid]?.kill(); + return killResult; + } + + @override + Future run( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }) async { + commands.add(command); + final mockCommand = _findCommand(command); + return ProcessResult( + mockCommand.pid, + await (mockCommand.exitCode ?? Future.value(0)), + mockCommand.stdout ?? '', + mockCommand.stderr ?? '', + ); + } + + @override + bool canRun(Object? executable, {String? workingDirectory}) => true; + + @override + ProcessResult runSync( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }) { + throw UnimplementedError(); + } +} + +class MockProcess implements Process { + @override + final Stream> stdout; + @override + final Stream> stderr; + @override + final int pid; + + @override + late final Future exitCode; + final Completer exitCodeCompleter = Completer(); + + bool killed = false; + + MockProcess({ + required this.stdout, + required this.stderr, + required this.pid, + Future? exitCodeFuture, + }) { + exitCode = exitCodeFuture ?? exitCodeCompleter.future; + } + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + killed = true; + if (!exitCodeCompleter.isCompleted) { + exitCodeCompleter.complete(-9); // SIGKILL + } + return true; + } + + @override + late final IOSink stdin = IOSink(StreamController>().sink); +} diff --git a/pkgs/dart_mcp_server/tool/update_readme.dart b/pkgs/dart_mcp_server/tool/update_readme.dart index f628319a..ea7d6732 100644 --- a/pkgs/dart_mcp_server/tool/update_readme.dart +++ b/pkgs/dart_mcp_server/tool/update_readme.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:dart_mcp/client.dart'; import 'package:dart_mcp/stdio.dart'; @@ -12,6 +13,7 @@ void main(List args) async { print('Getting registered tools...'); final tools = await _retrieveRegisteredTools(); + tools.sortBy((tool) => tool.name); final buf = StringBuffer(''' | Tool Name | Title | Description |