diff --git a/pkgs/dart_mcp_server/CHANGELOG.md b/pkgs/dart_mcp_server/CHANGELOG.md index 3802d73..107ce79 100644 --- a/pkgs/dart_mcp_server/CHANGELOG.md +++ b/pkgs/dart_mcp_server/CHANGELOG.md @@ -18,6 +18,8 @@ * 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. +* Add extra log output to failed launches, and allow AI to specify the maxLines + of log output. # 0.1.0 (Dart SDK 3.9.0) 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..01c9651 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/flutter_launcher.dart @@ -8,6 +8,7 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:dart_mcp/server.dart'; @@ -170,13 +171,21 @@ base mixin FlutterLauncherSupport LoggingLevel.info, 'Flutter application ${process!.pid} exited with code $exitCode.', ); - _runningApps.remove(process.pid); if (!completer.isCompleted) { + final logs = _runningApps[process.pid]?.logs ?? []; + // Only output the last 500 lines of logs. + final startLine = math.max(0, logs.length - 500); + final logOutput = [ + if (startLine > 0) '[skipping $startLine log lines]...', + ...logs.sublist(startLine), + ]; completer.completeError( 'Flutter application exited with code $exitCode before the DTD ' - 'URI was found.', + 'URI was found, with log output:\n${logOutput.join('\n')}', ); } + _runningApps.remove(process.pid); + // Cancel subscriptions after all processing is done. await stdoutSubscription.cancel(); await stderrSubscription.cancel(); @@ -360,6 +369,11 @@ base mixin FlutterLauncherSupport 'The process ID of the flutter run process running the ' 'application.', ), + 'maxLines': Schema.int( + description: + 'The maximum number of log lines to return from the end of the ' + 'logs. Defaults to 500. If set to -1, all logs will be returned.', + ), }, required: ['pid'], ), @@ -376,8 +390,9 @@ base mixin FlutterLauncherSupport Future _getAppLogs(CallToolRequest request) async { final pid = request.arguments!['pid'] as int; + var maxLines = request.arguments!['maxLines'] as int? ?? 500; log(LoggingLevel.info, 'Getting logs for application with PID: $pid'); - final logs = _runningApps[pid]?.logs; + var logs = _runningApps[pid]?.logs; if (logs == null) { log( @@ -394,6 +409,17 @@ base mixin FlutterLauncherSupport ); } + if (maxLines == -1) { + maxLines = logs.length; + } + if (maxLines > 0 && maxLines <= logs.length) { + final startLine = logs.length - maxLines; + logs = [ + if (startLine > 0) '[skipping $startLine log lines]...', + ...logs.sublist(startLine), + ]; + } + return CallToolResult( content: [TextContent(text: logs.join('\n'))], structuredContent: {'logs': logs}, 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..43666dd 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,285 @@ void main() { await client.shutdown(); }, ); + + test.test('launch_app tool fails when process exits early', () 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}}}''', + ), + ); + 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: 'Something went wrong', + exitCode: Future.value(1), + ), + ); + 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(); + + // Call the tool + final result = await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': projectRoot, 'device': 'test-device'}, + ), + ); + + test.expect(result.isError, true); + final textOutput = result.content as List; + test.expect( + textOutput.map((context) => context.text).toList().join('\n'), + test.stringContainsInOrder([ + 'Flutter application exited with code 1 before the DTD URI was found', + 'with log output', + 'Something went wrong', + ]), + ); + await server.shutdown(); + await client.shutdown(); + }); + + test.test('stop_app tool stops a 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 and launch the app + await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + client.notifyInitialized(); + await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': projectRoot, 'device': 'test-device'}, + ), + ); + + // Stop the app + final result = await client.callTool( + CallToolRequest(name: 'stop_app', arguments: {'pid': processPid}), + ); + + test.expect(result.isError, test.isNot(true)); + test.expect(result.structuredContent, {'success': true}); + test.expect(mockProcessManager.killedPids, [processPid]); + await server.shutdown(); + await client.shutdown(); + }); + + test.test('get_app_logs tool respects maxLines', () 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: + 'line 1\nline 2\nline 3\n' + '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 and launch the app + await client.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + client.notifyInitialized(); + await client.callTool( + CallToolRequest( + name: 'launch_app', + arguments: {'root': projectRoot, 'device': 'test-device'}, + ), + ); + + // Get the logs + final result = await client.callTool( + CallToolRequest( + name: 'get_app_logs', + arguments: {'pid': processPid, 'maxLines': 2}, + ), + ); + + test.expect(result.isError, test.isNot(true)); + test.expect(result.structuredContent, { + 'logs': [ + '[skipping 2 log lines]...', + '[stdout] line 3', + '[stdout] The Dart Tooling Daemon is available at: ws://127.0.0.1:12345/abcdefg=', + ], + }); + await server.shutdown(); + await client.shutdown(); + }); + + test.test('list_devices tool returns available devices', () 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}}} +''', + ), + ); + mockProcessManager.addCommand( + Command( + [ + Platform.isWindows + ? r'C:\path\to\flutter\sdk\bin\flutter.bat' + : '/path/to/flutter/sdk/bin/flutter', + 'devices', + '--machine', + ], + stdout: jsonEncode([ + {'id': 'test-device-1'}, + {'id': 'test-device-2'}, + ]), + ), + ); + 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(); + + // List devices + final result = await client.callTool( + CallToolRequest(name: 'list_devices', arguments: {}), + ); + + test.expect(result.isError, test.isNot(true)); + test.expect(result.structuredContent, { + 'devices': ['test-device-1', 'test-device-2'], + }); + await server.shutdown(); + await client.shutdown(); + }); }); }