diff --git a/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart index afd8d6f..bac401d 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart @@ -40,6 +40,7 @@ base mixin FlutterLauncherSupport registerTool(listDevicesTool, _listDevices); registerTool(getAppLogsTool, _getAppLogs); registerTool(listRunningAppsTool, _listRunningApps); + registerTool(hotRestartTool, _hotRestart); return super.initialize(request); } @@ -453,6 +454,73 @@ base mixin FlutterLauncherSupport ); } + /// A tool to perform a hot restart on a running Flutter application. + final hotRestartTool = Tool( + name: 'hot_restart', + description: + 'Performs a hot restart on a running Flutter application. This restarts the app while maintaining the current session.', + inputSchema: Schema.object( + properties: { + 'pid': Schema.int( + description: + 'The process ID of the flutter run process to hot restart.', + ), + }, + required: ['pid'], + ), + outputSchema: Schema.object( + properties: { + 'success': Schema.bool( + description: 'Whether the hot restart was successful.', + ), + }, + required: ['success'], + ), + ); + + Future _hotRestart(CallToolRequest request) async { + final pid = request.arguments!['pid'] as int; + log(LoggingLevel.info, 'Attempting hot restart for application 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.')], + ); + } + + try { + app.process.stdin.writeln('R'); + await app.process.stdin.flush(); + log(LoggingLevel.info, 'Hot restart command sent to application $pid.'); + + return CallToolResult( + content: [ + TextContent( + text: 'Hot restart initiated for application with PID $pid.', + ), + ], + structuredContent: {'success': true}, + ); + } catch (e, s) { + log( + LoggingLevel.error, + 'Error performing hot restart for application $pid: $e\n$s', + ); + return CallToolResult( + isError: true, + content: [ + TextContent( + text: 'Failed to perform hot restart for application $pid: $e', + ), + ], + structuredContent: {'success': false}, + ); + } + } + @override Future shutdown() { log(LoggingLevel.info, 'Shutting down server, killing all processes.'); diff --git a/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart b/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart index 7ec8b5e..5d9dd3f 100644 --- a/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart +++ b/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart @@ -197,6 +197,119 @@ void main() { await client.shutdown(); }, ); + + test.test('hot_restart tool sends restart command to running app', () 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 + await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + client.notifyInitialized(); + + // Launch the app first + final launchResult = await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': projectRoot, 'device': 'test-device'}, + ), + ); + test.expect(launchResult.isError, test.isNot(true)); + + // Perform hot restart + final restartResult = await client.callTool( + CallToolRequest(name: 'hot_restart', arguments: {'pid': processPid}), + ); + + test.expect(restartResult.isError, test.isNot(true)); + test.expect(restartResult.structuredContent, {'success': true}); + await server.shutdown(); + await client.shutdown(); + }); + + test.test('hot_restart tool returns error for non-existent app', () async { + 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}}}''', + ), + ); + final serverAndClient = await createServerAndClient( + processManager: mockProcessManager, + fileSystem: fileSystem, + ); + final server = serverAndClient.server; + final client = serverAndClient.client; + + // Initialize + await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + client.notifyInitialized(); + + // Try to hot restart a non-existent app + final restartResult = await client.callTool( + CallToolRequest(name: 'hot_restart', arguments: {'pid': 99999}), + ); + + test.expect(restartResult.isError, true); + await server.shutdown(); + await client.shutdown(); + }); }); } @@ -323,6 +436,9 @@ class MockProcess implements Process { bool killed = false; + @override + late final IOSink stdin = _MockIOSink(); + MockProcess({ required this.stdout, required this.stderr, @@ -340,7 +456,61 @@ class MockProcess implements Process { } return true; } +} + +class _MockIOSink implements IOSink { + final List writtenLines = []; + bool _closed = false; @override - late final IOSink stdin = IOSink(StreamController>().sink); + Encoding encoding = utf8; + + @override + void add(List data) { + if (_closed) throw StateError('IOSink is closed'); + } + + @override + void write(Object? object) { + if (_closed) throw StateError('IOSink is closed'); + } + + @override + void writeln([Object? object = '']) { + if (_closed) throw StateError('IOSink is closed'); + writtenLines.add(object.toString()); + } + + @override + Future flush() async { + if (_closed) throw StateError('IOSink is closed'); + } + + @override + Future close() async { + _closed = true; + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + throw UnimplementedError(); + } + + @override + Future addStream(Stream> stream) { + throw UnimplementedError(); + } + + @override + Future get done => Future.value(); + + @override + void writeAll(Iterable objects, [String separator = '']) { + throw UnimplementedError(); + } + + @override + void writeCharCode(int charCode) { + throw UnimplementedError(); + } }