From 743cc3536e40d49788df4553311a9935f00dfaa8 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Tue, 7 Oct 2025 20:14:56 +0300 Subject: [PATCH 1/5] feat: register hot_restart tool in initialization --- pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart | 1 + 1 file changed, 1 insertion(+) 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..bdedc2f 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); } From 6fd53b1529db6aaff0bc2bc06adbeb8e8d01496f Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Tue, 7 Oct 2025 20:15:13 +0300 Subject: [PATCH 2/5] feat: define hot_restart tool schema Add tool definition with input schema for process ID and output schema for success status --- .../lib/src/mixins/flutter_launcher.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 bdedc2f..65df461 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart @@ -454,6 +454,30 @@ 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'], + ), + ); + @override Future shutdown() { log(LoggingLevel.info, 'Shutting down server, killing all processes.'); From 02c07551338d0ed26a911ee9c0f7ed1212c94be7 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Tue, 7 Oct 2025 20:15:30 +0300 Subject: [PATCH 3/5] feat: implement hot_restart handler Send 'R' command to Flutter process stdin to trigger hot restart while maintaining app session --- .../lib/src/mixins/flutter_launcher.dart | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 65df461..bac401d 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart @@ -478,6 +478,49 @@ base mixin FlutterLauncherSupport ), ); + 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.'); From 45fb29c3e296fb54a91552f96aae2ba2483a6f17 Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Tue, 7 Oct 2025 20:15:49 +0300 Subject: [PATCH 4/5] test: add mock IOSink for process stdin testing --- .../test/tools/flutter_launcher_test.dart | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) 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..e6af17c 100644 --- a/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart +++ b/pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart @@ -323,6 +323,9 @@ class MockProcess implements Process { bool killed = false; + @override + late final IOSink stdin = _MockIOSink(); + MockProcess({ required this.stdout, required this.stderr, @@ -340,7 +343,61 @@ class MockProcess implements Process { } return true; } +} + +class _MockIOSink implements IOSink { + final List writtenLines = []; + bool _closed = false; + + @override + Encoding encoding = utf8; + + @override + void add(List data) { + if (_closed) throw StateError('IOSink is closed'); + } @override - late final IOSink stdin = IOSink(StreamController>().sink); + 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(); + } } From b98f691c373db67baa8a98a8b9f3e78dd8a33bef Mon Sep 17 00:00:00 2001 From: Stefano Amorelli Date: Tue, 7 Oct 2025 20:16:16 +0300 Subject: [PATCH 5/5] test: add hot_restart success test case --- .../test/tools/flutter_launcher_test.dart | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) 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 e6af17c..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(); + }); }); }