flutter-skill-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,594 @@
1
+ import 'dart:async';
2
+ import 'dart:convert';
3
+ import 'dart:io';
4
+
5
+ import 'package:http/http.dart' as http;
6
+ import '../flutter_skill_client.dart';
7
+ import 'setup.dart';
8
+
9
+ const String _currentVersion = '0.2.0';
10
+
11
+ Future<void> runServer(List<String> args) async {
12
+ // Check for updates in background
13
+ _checkForUpdates();
14
+
15
+ final server = FlutterMcpServer();
16
+ await server.run();
17
+ }
18
+
19
+ /// Check pub.dev for newer version
20
+ Future<void> _checkForUpdates() async {
21
+ try {
22
+ final response = await http.get(
23
+ Uri.parse('https://pub.dev/api/packages/flutter_skill'),
24
+ ).timeout(const Duration(seconds: 5));
25
+
26
+ if (response.statusCode == 200) {
27
+ final data = jsonDecode(response.body);
28
+ final latestVersion = data['latest']?['version'] as String?;
29
+
30
+ if (latestVersion != null && _isNewerVersion(latestVersion, _currentVersion)) {
31
+ stderr.writeln('');
32
+ stderr.writeln('╔══════════════════════════════════════════════════════════╗');
33
+ stderr.writeln('║ flutter-skill v$latestVersion available (current: v$_currentVersion)');
34
+ stderr.writeln('║ ║');
35
+ stderr.writeln('║ Update with: ║');
36
+ stderr.writeln('║ dart pub global activate flutter_skill ║');
37
+ stderr.writeln('║ Or: ║');
38
+ stderr.writeln('║ npm update -g flutter-skill-mcp ║');
39
+ stderr.writeln('╚══════════════════════════════════════════════════════════╝');
40
+ stderr.writeln('');
41
+ }
42
+ }
43
+ } catch (e) {
44
+ // Ignore update check errors
45
+ }
46
+ }
47
+
48
+ /// Compare semantic versions
49
+ bool _isNewerVersion(String latest, String current) {
50
+ final latestParts = latest.split('.').map(int.tryParse).toList();
51
+ final currentParts = current.split('.').map(int.tryParse).toList();
52
+
53
+ for (int i = 0; i < 3; i++) {
54
+ final l = i < latestParts.length ? (latestParts[i] ?? 0) : 0;
55
+ final c = i < currentParts.length ? (currentParts[i] ?? 0) : 0;
56
+ if (l > c) return true;
57
+ if (l < c) return false;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ class FlutterMcpServer {
63
+ FlutterSkillClient? _client;
64
+ Process? _flutterProcess;
65
+
66
+ Future<void> run() async {
67
+ stdin.transform(utf8.decoder).transform(const LineSplitter()).listen((line) async {
68
+ if (line.trim().isEmpty) return;
69
+ try {
70
+ final request = jsonDecode(line);
71
+ if (request is Map<String, dynamic>) {
72
+ await _handleRequest(request);
73
+ }
74
+ } catch (e) {
75
+ _sendError(null, -32700, "Parse error: $e");
76
+ }
77
+ });
78
+ }
79
+
80
+ Future<void> _handleRequest(Map<String, dynamic> request) async {
81
+ final id = request['id'];
82
+ final method = request['method'];
83
+ final params = request['params'] as Map<String, dynamic>? ?? {};
84
+
85
+ try {
86
+ if (method == 'initialize') {
87
+ _sendResult(id, {
88
+ "capabilities": {"tools": {}, "resources": {}},
89
+ "protocolVersion": "2024-11-05",
90
+ "serverInfo": {"name": "flutter-skill-mcp", "version": _currentVersion},
91
+ });
92
+ } else if (method == 'notifications/initialized') {
93
+ // No op
94
+ } else if (method == 'tools/list') {
95
+ _sendResult(id, {"tools": _getToolsList()});
96
+ } else if (method == 'tools/call') {
97
+ final name = params['name'];
98
+ final args = params['arguments'] as Map<String, dynamic>? ?? {};
99
+ final result = await _executeTool(name, args);
100
+ _sendResult(id, {
101
+ "content": [
102
+ {"type": "text", "text": jsonEncode(result)},
103
+ ],
104
+ });
105
+ }
106
+ } catch (e) {
107
+ if (id != null) {
108
+ _sendError(id, -32603, "Internal error: $e");
109
+ }
110
+ }
111
+ }
112
+
113
+ List<Map<String, dynamic>> _getToolsList() {
114
+ return [
115
+ // Connection
116
+ {
117
+ "name": "connect_app",
118
+ "description": "Connect to a running Flutter App VM Service",
119
+ "inputSchema": {
120
+ "type": "object",
121
+ "properties": {
122
+ "uri": {"type": "string", "description": "WebSocket URI (ws://...)"},
123
+ },
124
+ "required": ["uri"],
125
+ },
126
+ },
127
+ {
128
+ "name": "launch_app",
129
+ "description": "Launch a Flutter app and auto-connect",
130
+ "inputSchema": {
131
+ "type": "object",
132
+ "properties": {
133
+ "project_path": {"type": "string", "description": "Path to Flutter project"},
134
+ "device_id": {"type": "string", "description": "Target device"},
135
+ },
136
+ },
137
+ },
138
+
139
+ // Basic Inspection
140
+ {
141
+ "name": "inspect",
142
+ "description": "Get interactive elements (buttons, text fields, etc.)",
143
+ "inputSchema": {"type": "object", "properties": {}},
144
+ },
145
+ {
146
+ "name": "get_widget_tree",
147
+ "description": "Get the full widget tree structure",
148
+ "inputSchema": {
149
+ "type": "object",
150
+ "properties": {
151
+ "max_depth": {"type": "integer", "description": "Maximum tree depth (default: 10)"},
152
+ },
153
+ },
154
+ },
155
+ {
156
+ "name": "get_widget_properties",
157
+ "description": "Get properties of a widget by key",
158
+ "inputSchema": {
159
+ "type": "object",
160
+ "properties": {
161
+ "key": {"type": "string", "description": "Widget key"},
162
+ },
163
+ "required": ["key"],
164
+ },
165
+ },
166
+ {
167
+ "name": "get_text_content",
168
+ "description": "Get all text content on the screen",
169
+ "inputSchema": {"type": "object", "properties": {}},
170
+ },
171
+ {
172
+ "name": "find_by_type",
173
+ "description": "Find widgets by type name",
174
+ "inputSchema": {
175
+ "type": "object",
176
+ "properties": {
177
+ "type": {"type": "string", "description": "Widget type name to search"},
178
+ },
179
+ "required": ["type"],
180
+ },
181
+ },
182
+
183
+ // Basic Actions
184
+ {
185
+ "name": "tap",
186
+ "description": "Tap an element",
187
+ "inputSchema": {
188
+ "type": "object",
189
+ "properties": {
190
+ "key": {"type": "string", "description": "Widget key"},
191
+ "text": {"type": "string", "description": "Text to find"},
192
+ },
193
+ },
194
+ },
195
+ {
196
+ "name": "enter_text",
197
+ "description": "Enter text into an input field",
198
+ "inputSchema": {
199
+ "type": "object",
200
+ "properties": {
201
+ "key": {"type": "string", "description": "TextField key"},
202
+ "text": {"type": "string", "description": "Text to enter"},
203
+ },
204
+ "required": ["key", "text"],
205
+ },
206
+ },
207
+ {
208
+ "name": "scroll_to",
209
+ "description": "Scroll to make an element visible",
210
+ "inputSchema": {
211
+ "type": "object",
212
+ "properties": {
213
+ "key": {"type": "string", "description": "Widget key"},
214
+ "text": {"type": "string", "description": "Text to find"},
215
+ },
216
+ },
217
+ },
218
+
219
+ // Advanced Actions
220
+ {
221
+ "name": "long_press",
222
+ "description": "Long press on an element",
223
+ "inputSchema": {
224
+ "type": "object",
225
+ "properties": {
226
+ "key": {"type": "string", "description": "Widget key"},
227
+ "text": {"type": "string", "description": "Text to find"},
228
+ "duration": {"type": "integer", "description": "Duration in ms (default: 500)"},
229
+ },
230
+ },
231
+ },
232
+ {
233
+ "name": "double_tap",
234
+ "description": "Double tap on an element",
235
+ "inputSchema": {
236
+ "type": "object",
237
+ "properties": {
238
+ "key": {"type": "string", "description": "Widget key"},
239
+ "text": {"type": "string", "description": "Text to find"},
240
+ },
241
+ },
242
+ },
243
+ {
244
+ "name": "swipe",
245
+ "description": "Perform a swipe gesture",
246
+ "inputSchema": {
247
+ "type": "object",
248
+ "properties": {
249
+ "direction": {"type": "string", "enum": ["up", "down", "left", "right"]},
250
+ "distance": {"type": "number", "description": "Swipe distance in pixels (default: 300)"},
251
+ "key": {"type": "string", "description": "Start from element (optional)"},
252
+ },
253
+ "required": ["direction"],
254
+ },
255
+ },
256
+ {
257
+ "name": "drag",
258
+ "description": "Drag from one element to another",
259
+ "inputSchema": {
260
+ "type": "object",
261
+ "properties": {
262
+ "from_key": {"type": "string", "description": "Source element key"},
263
+ "to_key": {"type": "string", "description": "Target element key"},
264
+ },
265
+ "required": ["from_key", "to_key"],
266
+ },
267
+ },
268
+
269
+ // State & Validation
270
+ {
271
+ "name": "get_text_value",
272
+ "description": "Get current value of a text field",
273
+ "inputSchema": {
274
+ "type": "object",
275
+ "properties": {
276
+ "key": {"type": "string", "description": "TextField key"},
277
+ },
278
+ "required": ["key"],
279
+ },
280
+ },
281
+ {
282
+ "name": "get_checkbox_state",
283
+ "description": "Get state of a checkbox or switch",
284
+ "inputSchema": {
285
+ "type": "object",
286
+ "properties": {
287
+ "key": {"type": "string", "description": "Checkbox/Switch key"},
288
+ },
289
+ "required": ["key"],
290
+ },
291
+ },
292
+ {
293
+ "name": "get_slider_value",
294
+ "description": "Get current value of a slider",
295
+ "inputSchema": {
296
+ "type": "object",
297
+ "properties": {
298
+ "key": {"type": "string", "description": "Slider key"},
299
+ },
300
+ "required": ["key"],
301
+ },
302
+ },
303
+ {
304
+ "name": "wait_for_element",
305
+ "description": "Wait for an element to appear",
306
+ "inputSchema": {
307
+ "type": "object",
308
+ "properties": {
309
+ "key": {"type": "string", "description": "Widget key"},
310
+ "text": {"type": "string", "description": "Text to find"},
311
+ "timeout": {"type": "integer", "description": "Timeout in ms (default: 5000)"},
312
+ },
313
+ },
314
+ },
315
+ {
316
+ "name": "wait_for_gone",
317
+ "description": "Wait for an element to disappear",
318
+ "inputSchema": {
319
+ "type": "object",
320
+ "properties": {
321
+ "key": {"type": "string", "description": "Widget key"},
322
+ "text": {"type": "string", "description": "Text to find"},
323
+ "timeout": {"type": "integer", "description": "Timeout in ms (default: 5000)"},
324
+ },
325
+ },
326
+ },
327
+
328
+ // Screenshot
329
+ {
330
+ "name": "screenshot",
331
+ "description": "Take a screenshot of the app",
332
+ "inputSchema": {"type": "object", "properties": {}},
333
+ },
334
+ {
335
+ "name": "screenshot_element",
336
+ "description": "Take a screenshot of a specific element",
337
+ "inputSchema": {
338
+ "type": "object",
339
+ "properties": {
340
+ "key": {"type": "string", "description": "Element key"},
341
+ },
342
+ "required": ["key"],
343
+ },
344
+ },
345
+
346
+ // Navigation
347
+ {
348
+ "name": "get_current_route",
349
+ "description": "Get the current route name",
350
+ "inputSchema": {"type": "object", "properties": {}},
351
+ },
352
+ {
353
+ "name": "go_back",
354
+ "description": "Navigate back",
355
+ "inputSchema": {"type": "object", "properties": {}},
356
+ },
357
+ {
358
+ "name": "get_navigation_stack",
359
+ "description": "Get the navigation stack",
360
+ "inputSchema": {"type": "object", "properties": {}},
361
+ },
362
+
363
+ // Debug & Logs
364
+ {
365
+ "name": "get_logs",
366
+ "description": "Get application logs",
367
+ "inputSchema": {"type": "object", "properties": {}},
368
+ },
369
+ {
370
+ "name": "get_errors",
371
+ "description": "Get application errors",
372
+ "inputSchema": {"type": "object", "properties": {}},
373
+ },
374
+ {
375
+ "name": "clear_logs",
376
+ "description": "Clear logs and errors",
377
+ "inputSchema": {"type": "object", "properties": {}},
378
+ },
379
+ {
380
+ "name": "get_performance",
381
+ "description": "Get performance metrics",
382
+ "inputSchema": {"type": "object", "properties": {}},
383
+ },
384
+
385
+ // Utilities
386
+ {
387
+ "name": "hot_reload",
388
+ "description": "Trigger hot reload",
389
+ "inputSchema": {"type": "object", "properties": {}},
390
+ },
391
+ {
392
+ "name": "pub_search",
393
+ "description": "Search Flutter packages on pub.dev",
394
+ "inputSchema": {
395
+ "type": "object",
396
+ "properties": {
397
+ "query": {"type": "string", "description": "Search query"},
398
+ },
399
+ "required": ["query"],
400
+ },
401
+ },
402
+ ];
403
+ }
404
+
405
+ Future<dynamic> _executeTool(String name, Map<String, dynamic> args) async {
406
+ // Connection tools
407
+ if (name == 'connect_app') {
408
+ final uri = args['uri'] as String;
409
+ if (_client != null) await _client!.disconnect();
410
+ _client = FlutterSkillClient(uri);
411
+ await _client!.connect();
412
+ return "Connected to $uri";
413
+ }
414
+
415
+ if (name == 'launch_app') {
416
+ final projectPath = args['project_path'] ?? '.';
417
+ final deviceId = args['device_id'];
418
+
419
+ if (_flutterProcess != null) {
420
+ _flutterProcess!.kill();
421
+ _flutterProcess = null;
422
+ }
423
+
424
+ final processArgs = ['run'];
425
+ if (deviceId != null) processArgs.addAll(['-d', deviceId]);
426
+
427
+ try {
428
+ await runSetup(projectPath);
429
+ } catch (e) {
430
+ // Continue even if setup fails
431
+ }
432
+
433
+ _flutterProcess = await Process.start('flutter', processArgs, workingDirectory: projectPath);
434
+
435
+ final completer = Completer<String>();
436
+
437
+ _flutterProcess!.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
438
+ if (line.contains('ws://')) {
439
+ final uriRegex = RegExp(r'ws://[a-zA-Z0-9.:/-]+');
440
+ final match = uriRegex.firstMatch(line);
441
+ if (match != null) {
442
+ final uri = match.group(0)!;
443
+ _client?.disconnect();
444
+ _client = FlutterSkillClient(uri);
445
+ _client!.connect().then((_) {
446
+ if (!completer.isCompleted) completer.complete("Launched and connected to $uri");
447
+ }).catchError((e) {
448
+ if (!completer.isCompleted) completer.completeError("Found URI but failed to connect: $e");
449
+ });
450
+ }
451
+ }
452
+ });
453
+
454
+ _flutterProcess!.exitCode.then((code) {
455
+ if (!completer.isCompleted) {
456
+ completer.completeError("Flutter app exited with code $code");
457
+ }
458
+ _flutterProcess = null;
459
+ });
460
+
461
+ return completer.future.timeout(
462
+ const Duration(seconds: 120),
463
+ onTimeout: () => "Timed out waiting for app to start",
464
+ );
465
+ }
466
+
467
+ if (name == 'pub_search') {
468
+ final query = args['query'];
469
+ final url = Uri.parse('https://pub.dev/api/search?q=$query');
470
+ final response = await http.get(url);
471
+ if (response.statusCode != 200) throw Exception("Pub search failed");
472
+ final json = jsonDecode(response.body);
473
+ return json['packages'];
474
+ }
475
+
476
+ if (name == 'hot_reload') {
477
+ _requireConnection();
478
+ await _client!.hotReload();
479
+ return "Hot reload triggered";
480
+ }
481
+
482
+ // Require connection for all other tools
483
+ _requireConnection();
484
+
485
+ switch (name) {
486
+ // Inspection
487
+ case 'inspect':
488
+ return await _client!.getInteractiveElements();
489
+ case 'get_widget_tree':
490
+ final maxDepth = args['max_depth'] ?? 10;
491
+ return await _client!.getWidgetTree(maxDepth: maxDepth);
492
+ case 'get_widget_properties':
493
+ return await _client!.getWidgetProperties(args['key']);
494
+ case 'get_text_content':
495
+ return await _client!.getTextContent();
496
+ case 'find_by_type':
497
+ return await _client!.findByType(args['type']);
498
+
499
+ // Basic Actions
500
+ case 'tap':
501
+ await _client!.tap(key: args['key'], text: args['text']);
502
+ return "Tapped";
503
+ case 'enter_text':
504
+ await _client!.enterText(args['key'], args['text']);
505
+ return "Entered text";
506
+ case 'scroll_to':
507
+ await _client!.scrollTo(key: args['key'], text: args['text']);
508
+ return "Scrolled";
509
+
510
+ // Advanced Actions
511
+ case 'long_press':
512
+ final duration = args['duration'] ?? 500;
513
+ final success = await _client!.longPress(key: args['key'], text: args['text'], duration: duration);
514
+ return success ? "Long pressed" : "Long press failed";
515
+ case 'double_tap':
516
+ final success = await _client!.doubleTap(key: args['key'], text: args['text']);
517
+ return success ? "Double tapped" : "Double tap failed";
518
+ case 'swipe':
519
+ final distance = (args['distance'] ?? 300).toDouble();
520
+ final success = await _client!.swipe(direction: args['direction'], distance: distance, key: args['key']);
521
+ return success ? "Swiped ${args['direction']}" : "Swipe failed";
522
+ case 'drag':
523
+ final success = await _client!.drag(fromKey: args['from_key'], toKey: args['to_key']);
524
+ return success ? "Dragged" : "Drag failed";
525
+
526
+ // State & Validation
527
+ case 'get_text_value':
528
+ return await _client!.getTextValue(args['key']);
529
+ case 'get_checkbox_state':
530
+ return await _client!.getCheckboxState(args['key']);
531
+ case 'get_slider_value':
532
+ return await _client!.getSliderValue(args['key']);
533
+ case 'wait_for_element':
534
+ final timeout = args['timeout'] ?? 5000;
535
+ final found = await _client!.waitForElement(key: args['key'], text: args['text'], timeout: timeout);
536
+ return {"found": found};
537
+ case 'wait_for_gone':
538
+ final timeout = args['timeout'] ?? 5000;
539
+ final gone = await _client!.waitForGone(key: args['key'], text: args['text'], timeout: timeout);
540
+ return {"gone": gone};
541
+
542
+ // Screenshot
543
+ case 'screenshot':
544
+ final image = await _client!.takeScreenshot();
545
+ return {"image": image};
546
+ case 'screenshot_element':
547
+ final image = await _client!.takeElementScreenshot(args['key']);
548
+ return {"image": image};
549
+
550
+ // Navigation
551
+ case 'get_current_route':
552
+ return await _client!.getCurrentRoute();
553
+ case 'go_back':
554
+ final success = await _client!.goBack();
555
+ return success ? "Navigated back" : "Cannot go back";
556
+ case 'get_navigation_stack':
557
+ return await _client!.getNavigationStack();
558
+
559
+ // Debug & Logs
560
+ case 'get_logs':
561
+ return await _client!.getLogs();
562
+ case 'get_errors':
563
+ return await _client!.getErrors();
564
+ case 'clear_logs':
565
+ await _client!.clearLogs();
566
+ return "Logs cleared";
567
+ case 'get_performance':
568
+ return await _client!.getPerformance();
569
+
570
+ default:
571
+ throw Exception("Unknown tool: $name");
572
+ }
573
+ }
574
+
575
+ void _requireConnection() {
576
+ if (_client == null || !_client!.isConnected) {
577
+ throw Exception("Not connected. Call 'connect_app' or 'launch_app' first.");
578
+ }
579
+ }
580
+
581
+ void _sendResult(dynamic id, dynamic result) {
582
+ if (id == null) return;
583
+ stdout.writeln(jsonEncode({"jsonrpc": "2.0", "id": id, "result": result}));
584
+ }
585
+
586
+ void _sendError(dynamic id, int code, String message) {
587
+ if (id == null) return;
588
+ stdout.writeln(jsonEncode({
589
+ "jsonrpc": "2.0",
590
+ "id": id,
591
+ "error": {"code": code, "message": message},
592
+ }));
593
+ }
594
+ }
@@ -0,0 +1,76 @@
1
+ import 'dart:io';
2
+
3
+ Future<void> runSetup(String projectPath) async {
4
+ final pubspecFile = File('$projectPath/pubspec.yaml');
5
+ final mainFile = File('$projectPath/lib/main.dart');
6
+
7
+ if (!pubspecFile.existsSync()) {
8
+ print('Error: pubspec.yaml not found at $projectPath');
9
+ exit(1);
10
+ }
11
+
12
+ if (!mainFile.existsSync()) {
13
+ print('Error: lib/main.dart not found at $projectPath');
14
+ exit(1);
15
+ }
16
+
17
+ print('Checking dependencies in ${pubspecFile.path}...');
18
+ final pubspecContent = pubspecFile.readAsStringSync();
19
+
20
+ if (!pubspecContent.contains('flutter_skill:')) {
21
+ print('Adding flutter_skill dependency...');
22
+ final result = await Process.run(
23
+ 'flutter',
24
+ ['pub', 'add', 'flutter_skill'],
25
+ workingDirectory: projectPath,
26
+ );
27
+ if (result.exitCode != 0) {
28
+ print('Failed to add dependency: ${result.stderr}');
29
+ exit(1);
30
+ }
31
+ print('Dependency added.');
32
+ } else {
33
+ print('Dependency flutter_skill already exists.');
34
+ }
35
+
36
+ print('Checking instrumentation in ${mainFile.path}...');
37
+ String mainContent = mainFile.readAsStringSync();
38
+
39
+ bool changed = false;
40
+
41
+ // 1. Check Import
42
+ if (!mainContent.contains('package:flutter_skill/flutter_skill.dart')) {
43
+ mainContent =
44
+ "import 'package:flutter_skill/flutter_skill.dart';\nimport 'package:flutter/foundation.dart'; // For kDebugMode\n" +
45
+ mainContent;
46
+ changed = true;
47
+ print('Added imports.');
48
+ }
49
+
50
+ // 2. Check Initialization
51
+ if (!mainContent.contains('FlutterSkillBinding.ensureInitialized()')) {
52
+ final mainRegex = RegExp(r'void\s+main\s*\(\s*\)\s*\{');
53
+ final match = mainRegex.firstMatch(mainContent);
54
+
55
+ if (match != null) {
56
+ final end = match.end;
57
+ const injection =
58
+ '\n if (kDebugMode) {\n FlutterSkillBinding.ensureInitialized();\n }\n';
59
+ mainContent = mainContent.replaceRange(end, end, injection);
60
+ changed = true;
61
+ print('Added FlutterSkillBinding initialization.');
62
+ } else {
63
+ print(
64
+ 'Warning: Could not find "void main() {" to inject code. Manual setup required.');
65
+ }
66
+ }
67
+
68
+ if (changed) {
69
+ mainFile.writeAsStringSync(mainContent);
70
+ print('Updated lib/main.dart.');
71
+ } else {
72
+ print('No changes needed for lib/main.dart.');
73
+ }
74
+
75
+ print('Setup complete.');
76
+ }