Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ This extension also installs an MCP server (`flutter_launcher`) that provides to

- `launch_app`: Launches a Flutter application on a specified device.
- `stop_app`: Stops a running Flutter application.
- `hot_restart`: Performs a hot restart on a running Flutter application, resetting the app state while maintaining the current session.
- `list_devices`: Lists all available devices that can run Flutter applications.
- `get_app_logs`: Retrieves the logs from a running Flutter application.
- `list_running_apps`: Lists all Flutter applications currently running that were started by this extension.
Expand Down
34 changes: 34 additions & 0 deletions flutter_launcher_mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,40 @@ Launches a Flutter application with specified arguments and returns its DTD URI
}
```

#### `hot_restart`

Performs a hot restart on a running Flutter application. This restarts the app while maintaining the current session.

- **Input Schema:**

```json
{
"type": "object",
"properties": {
"pid": {
"type": "integer",
"description": "The process ID of the flutter run process to hot restart."
}
},
"required": ["pid"]
}
```

- **Output Schema:**

```json
{
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Whether the hot restart was successful."
}
},
"required": ["success"]
}
```

#### `stop_app`

Kills a running Flutter process started by the `launch_app` tool.
Expand Down
68 changes: 68 additions & 0 deletions flutter_launcher_mcp/lib/src/mixins/flutter_launcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ base mixin FlutterLauncherSupport
registerTool(listDevicesTool, _listDevices);
registerTool(getAppLogsTool, _getAppLogs);
registerTool(listRunningAppsTool, _listRunningApps);
registerTool(hotRestartTool, _hotRestart);
return super.initialize(request);
}

Expand Down Expand Up @@ -461,6 +462,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<CallToolResult> _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<void> shutdown() {
log(LoggingLevel.info, 'Shutting down server, killing all processes.');
Expand Down
59 changes: 58 additions & 1 deletion flutter_launcher_mcp/test/sdk_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ class MockProcess implements Process {

bool killed = false;

@override
late final IOSink stdin = _MockIOSink();

MockProcess({
required this.stdout,
required this.stderr,
Expand All @@ -268,7 +271,61 @@ class MockProcess implements Process {
}
return true;
}
}

class _MockIOSink implements IOSink {
final List<String> writtenLines = [];
bool _closed = false;

@override
Encoding encoding = utf8;

@override
void add(List<int> data) {
if (_closed) throw StateError('IOSink is closed');
}

@override
late final IOSink stdin = throw UnimplementedError();
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<void> flush() async {
if (_closed) throw StateError('IOSink is closed');
}

@override
Future<void> close() async {
_closed = true;
}

@override
void addError(Object error, [StackTrace? stackTrace]) {
throw UnimplementedError();
}

@override
Future addStream(Stream<List<int>> 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();
}
}
86 changes: 86 additions & 0 deletions flutter_launcher_mcp/test/server_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,5 +155,91 @@ 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(
[
'/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': '/test/project', '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();
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();
});
});
}