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
68 changes: 68 additions & 0 deletions pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ base mixin FlutterLauncherSupport
registerTool(listDevicesTool, _listDevices);
registerTool(getAppLogsTool, _getAppLogs);
registerTool(listRunningAppsTool, _listRunningApps);
registerTool(hotRestartTool, _hotRestart);
return super.initialize(request);
}

Expand Down Expand Up @@ -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<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
172 changes: 171 additions & 1 deletion pkgs/dart_mcp_server/test/tools/flutter_launcher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
}

Expand Down Expand Up @@ -323,6 +436,9 @@ class MockProcess implements Process {

bool killed = false;

@override
late final IOSink stdin = _MockIOSink();

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

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

@override
late final IOSink stdin = IOSink(StreamController<List<int>>().sink);
Encoding encoding = utf8;

@override
void add(List<int> 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<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();
}
}