-
Notifications
You must be signed in to change notification settings - Fork 238
Expand file tree
/
Copy pathmcp_server.dart
More file actions
546 lines (512 loc) · 19 KB
/
Copy pathmcp_server.dart
File metadata and controls
546 lines (512 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
import 'dart:async';
import 'dart:io' show Directory, stderr;
import 'package:args/command_runner.dart';
import 'package:dart_mcp/server.dart';
import 'package:mason/mason.dart' hide packageVersion;
import 'package:stream_channel/stream_channel.dart';
import 'package:very_good_cli/src/command_runner.dart';
import 'package:very_good_cli/src/version.dart';
/// {@template very_good_mcp_server}
/// MCP Server for Very Good CLI.
///
/// This server exposes Very Good CLI functionality through the
/// Model Context Protocol, allowing AI assistants to interact
/// with the CLI programmatically.
/// {@endtemplate}
final class VeryGoodMCPServer extends MCPServer with ToolsSupport {
/// {@macro very_good_mcp_server}
VeryGoodMCPServer({
required StreamChannel<String> channel,
Logger? logger,
VeryGoodCommandRunner? commandRunner,
}) : _commandRunner =
commandRunner ?? VeryGoodCommandRunner(logger: logger ?? Logger()),
super.fromStreamChannel(
channel,
implementation: Implementation(
name: 'very_good_cli',
version: packageVersion,
),
instructions:
'A Very Good CLI MCP server that provides tools '
'for creating and managing Dart/Flutter projects.',
);
final VeryGoodCommandRunner _commandRunner;
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) async {
final result = await super.initialize(request);
_registerTools();
return result;
}
void _registerTools() {
// Create project tool
registerTool(
Tool(
name: 'create',
description: '''
Create a very good Dart or Flutter project in seconds based on the provided template. Each template has a corresponding sub-command.
''',
inputSchema: ObjectSchema(
properties: {
'subcommand': UntitledSingleSelectEnumSchema(
description: '''
The available subcommands to provide an specific template, are:
app_ui_package - Generate a Very Good App UI package.
dart_cli - Generate a Very Good Dart CLI application.
dart_package - Generate a Very Good Dart package.
docs_site - Generate a Very Good documentation site.
flame_game - Generate a Very Good Flame game.
flutter_app - Generate a Very Good Flutter application.
flutter_package - Generate a Very Good Flutter package.
flutter_plugin - Generate a Very Good Flutter plugin.
''',
values: [
'app_ui_package',
'flame_game',
'flutter_app',
'flutter_package',
'flutter_plugin',
'dart_cli',
'dart_package',
'docs_site',
],
),
'name': StringSchema(description: 'Project name'),
'description': StringSchema(
description: '''
The description for this new project.
(defaults to "A Very Good Project created by Very Good CLI.")''',
),
'org_name': StringSchema(
description: '''
The organization for this new project.
(defaults to "com.example.verygoodcore")''',
),
'output_directory': StringSchema(
description:
'''The desired output directory when creating a new project.''',
),
'application_id': StringSchema(
description:
'''The bundle identifier on iOS or application id on Android. (defaults to <org-name>.<project-name>)''',
),
'platforms': StringSchema(
description: '''
Comma-separated platforms. 'Example: "android,ios,web".
The values for platforms are: android, ios, web, macos, linux, and windows.
If is omitted, then all platforms are enabled by default.
Only available for subcommands: flutter_plugin with all values) and flame_game (only android and ios)
''',
),
'publishable': BooleanSchema(
description: '''
Whether package is intended for publishing (flutter_package, dart_package only)''',
),
'executable-name': StringSchema(
description: '''
CLI custom executable name (dart_cli only)''',
),
'template': StringSchema(
description: '''
The template used to generate this new project.
The values are:
core - Generate a Very Good Flutter application.
If is omitted, then core will be selected.
''',
),
},
required: ['subcommand', 'name'],
),
),
_handleCreate,
);
// Test tool
registerTool(
Tool(
name: 'test',
description: 'Run tests in a Dart or Flutter project.',
inputSchema: ObjectSchema(
properties: {
'directory': StringSchema(
description:
'Target directory path (defaults to current directory). '
'Can be absolute or relative path to project root.',
),
'dart': BooleanSchema(
description:
'''Whether to run Dart tests. If not specified, Flutter tests will be run if a Flutter project is detected.''',
),
'coverage': BooleanSchema(
description: 'Whether to collect coverage information.',
),
'recursive': BooleanSchema(
description: 'Run tests recursively for all nested packages.',
),
'optimization': BooleanSchema(
description: '''
Whether to apply optimizations for test performance.
Automatically disabled when --platform is specified.
Add the `skip_very_good_optimization` tag to specific test files to disable them individually.
(defaults to on)''',
),
'concurrency': StringSchema(
description: '''
The number of concurrent test suites run.
Automatically set to 1 when --platform is specified.
(defaults to "4")''',
),
'tags': StringSchema(
description:
'''Run only tests associated with the specified tags.''',
),
'exclude_coverage': StringSchema(
description:
'''A glob which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart').''',
),
'exclude_tags': StringSchema(
description:
'Run only tests that do not have the specified tags.',
),
'min_coverage': StringSchema(
description:
'''Whether to enforce a minimum coverage percentage.''',
),
'test_randomize_ordering_seed': StringSchema(
description:
'''The seed to randomize the execution order of test cases within test files.''',
),
'update_goldens': BooleanSchema(
description: '''
Whether "matchesGoldenFile()" calls within your test methods should update the golden files.''',
),
'force_ansi': BooleanSchema(
description: '''
Whether to force ansi output. If not specified, it will maintain the default behavior based on stdout and stderr.''',
),
'dart-define': StringSchema(
description: '''
Additional key-value pairs that will be available as constants from the String.fromEnvironment, bool.fromEnvironment, int.fromEnvironment, and double.fromEnvironment constructors.
Multiple defines can be passed by repeating "--dart-define" multiple times.
(e.g., foo=bar)
''',
),
'dart-define-from-file': StringSchema(
description: '''
The path of a .json or .env file containing key-value pairs that will be available as environment variables. These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment constructors.
Multiple defines can be passed by repeating "--dart-define-from-file" multiple times. Entries from "--dart-define" with identical keys take precedence over entries from these files.''',
),
'platform': StringSchema(
description: '''
The platform to run tests on.
The available values are: chrome, vm, android, ios.
Only one value can be selected.
''',
),
'run_skipped': BooleanSchema(
description:
'Run skipped tests instead of skipping them. '
'Only applies to Dart tests (dart: true).',
),
'check_ignore': BooleanSchema(
description:
'Whether to check for and respect coverage ignore comments '
'(e.g. // coverage:ignore-line). '
'Only applies to Dart tests (dart: true).',
),
'timeout_seconds': IntegerSchema(
description:
'Maximum seconds to wait for the test run before killing '
'the Flutter test process. Flutter tests can hang '
'indefinitely when pumpAndSettle() is called without a '
'timeout argument. When omitted, no timeout is applied.',
),
},
),
),
_handleTest,
);
// Packages get tool
registerTool(
Tool(
name: 'packages_get',
description: '''
Install or update a Dart/Flutter package dependencies.
Use after creating a project or modifying pubspec.yaml.
Supports recursive installation and package exclusion.''',
inputSchema: ObjectSchema(
properties: {
'directory': StringSchema(
description:
'Target directory path (defaults to current directory). '
'Can be absolute or relative path to project root.',
),
'recursive': BooleanSchema(
description:
'Install dependencies for all nested packages recursively. '
'Useful for monorepos or projects with multiple packages.',
),
'ignore': StringSchema(
description:
'Comma-separated list of package names to skip. '
'Example: "package1,package2". '
'Useful to avoid processing problematic packages.',
),
},
),
),
_handlePackagesGet,
);
// Packages check licenses tool
registerTool(
Tool(
name: 'packages_check_licenses',
description: '''
Verify package licenses for compliance and validation in a Dart or Flutter project.
Identifies license types (MIT, BSD, Apache, etc.) for all
dependencies. Use to ensure license compatibility.''',
inputSchema: ObjectSchema(
properties: {
'directory': StringSchema(
description:
'Target directory path (defaults to current directory). '
'Path to the project root containing pubspec.yaml.',
),
'licenses': BooleanSchema(
description:
'Verify all package licenses (defaults to true). '
'Currently the only supported check type. '
'Reports license types for all dependencies.',
),
},
),
),
_handlePackagesCheck,
);
}
List<String> _parseCreate(Map<String, Object?> args) {
final subcommand = args['subcommand']! as String;
final name = args['name']! as String;
final cliArgs = <String>['create', subcommand, name];
if (args['description'] != null) {
cliArgs.addAll(['--desc', args['description']! as String]);
}
if (args['org_name'] != null) {
cliArgs.addAll(['--org-name', args['org_name']! as String]);
}
if (args['output_directory'] != null) {
cliArgs.addAll(['-o', args['output_directory']! as String]);
}
if (args['application_id'] != null) {
cliArgs.addAll(['--application-id', args['application_id']! as String]);
}
if (args['platforms'] != null) {
cliArgs.addAll(['--platforms', args['platforms']! as String]);
}
if (args['publishable'] == true) {
cliArgs.add('--publishable');
}
if (args['executable-name'] != null) {
cliArgs.addAll(['--executable-name', args['executable-name']! as String]);
}
if (args['template'] != null) {
cliArgs.addAll(['-t', args['template']! as String]);
}
return cliArgs;
}
List<String> _parseTest(Map<String, Object?> args) {
// NOTE: 'directory' is intentionally not added here. It is applied as the
// working directory in [_runToolCommand], not as a positional test target.
final cliArgs = <String>[if (args['dart'] == true) 'dart', 'test'];
if (args['coverage'] == true) {
cliArgs.add('--coverage');
}
if (args['recursive'] == true) {
cliArgs.add('-r');
}
if (args['optimization'] == false) {
cliArgs.add('--no-optimization');
}
if (args['concurrency'] != null) {
cliArgs.addAll(['-j', args['concurrency']! as String]);
}
if (args['tags'] != null) {
cliArgs.addAll(['-t', args['tags']! as String]);
}
if (args['exclude_coverage'] != null) {
cliArgs.addAll([
'--exclude-coverage',
args['exclude_coverage']! as String,
]);
}
if (args['exclude_tags'] != null) {
cliArgs.addAll(['-x', args['exclude_tags']! as String]);
}
if (args['min_coverage'] != null) {
cliArgs.addAll(['--min-coverage', args['min_coverage']! as String]);
}
if (args['test_randomize_ordering_seed'] != null) {
cliArgs.addAll([
'--test-randomize-ordering-seed',
args['test_randomize_ordering_seed']! as String,
]);
}
if (args['update_goldens'] == true) {
cliArgs.add('--update-goldens');
}
if (args['force_ansi'] == true) {
cliArgs.add('--force-ansi');
}
if (args['dart-define'] != null) {
cliArgs.addAll(['--dart-define', args['dart-define']! as String]);
}
if (args['dart-define-from-file'] != null) {
cliArgs.addAll([
'--dart-define-from-file',
args['dart-define-from-file']! as String,
]);
}
if (args['platform'] != null) {
cliArgs.addAll(['--platform', args['platform']! as String]);
}
if (args['run_skipped'] == true) {
cliArgs.add('--run-skipped');
}
if (args['check_ignore'] == true) {
cliArgs.add('--check-ignore');
}
if (args['timeout_seconds'] != null) {
cliArgs.addAll([
'--timeout',
(args['timeout_seconds']! as num).toInt().toString(),
]);
}
return cliArgs;
}
List<String> _parsePackagesGet(Map<String, Object?> args) {
// NOTE: 'directory' is applied as the working directory in
// [_runToolCommand], not as a positional argument.
final cliArgs = <String>['packages', 'get'];
if (args['recursive'] == true) {
cliArgs.add('--recursive');
}
if (args['ignore'] != null) {
final ignore = (args['ignore']! as String).split(',');
for (final pkg in ignore) {
cliArgs.addAll(['--ignore', pkg.trim()]);
}
}
return cliArgs;
}
List<String> _parsePackagesCheck(Map<String, Object?> args) {
// NOTE: 'directory' is applied as the working directory in
// [_runToolCommand], not as a positional argument.
final cliArgs = <String>['packages', 'check', 'licenses'];
return cliArgs;
}
Future<CallToolResult> _handleCreate(CallToolRequest request) async {
final args = request.arguments ?? {};
final cliArgs = _parseCreate(args);
return _runToolCommand(cliArgs, toolName: 'create');
}
Future<CallToolResult> _handleTest(CallToolRequest request) async {
final args = request.arguments ?? {};
final cliArgs = _parseTest(args);
return _runToolCommand(
cliArgs,
toolName: 'test',
workingDirectory: args['directory'] as String?,
);
}
Future<CallToolResult> _handlePackagesGet(CallToolRequest request) async {
final args = request.arguments ?? {};
final cliArgs = _parsePackagesGet(args);
return _runToolCommand(
cliArgs,
toolName: 'packages get',
workingDirectory: args['directory'] as String?,
);
}
Future<CallToolResult> _handlePackagesCheck(CallToolRequest request) async {
final args = request.arguments ?? {};
// Currently, 'packages check' only has 'licenses' as a subcommand
// Default to checking licenses if not explicitly set to false
final checkLicenses = args['licenses'] as bool? ?? true;
if (!checkLicenses) {
return CallToolResult(
content: [
TextContent(
text:
'No check specified. Currently only "licenses" check is '
'supported. Set licenses=true to run license checks.',
),
],
isError: true,
);
}
final cliArgs = _parsePackagesCheck(args);
return _runToolCommand(
cliArgs,
toolName: 'packages check licenses',
workingDirectory: args['directory'] as String?,
);
}
/// Runs a CLI command and returns a [CallToolResult] with descriptive
/// error messages including the command that was run and the exit code.
Future<CallToolResult> _runToolCommand(
List<String> args, {
required String toolName,
String? workingDirectory,
}) async {
final commandString = 'very_good ${args.join(' ')}';
// The underlying CLI commands resolve their target package from
// `Directory.current` (and child processes inherit the process cwd), so a
// requested [workingDirectory] is applied by switching the current
// directory for the duration of the run and restoring it afterwards.
// Relative paths are resolved against the server's current directory.
final previousDirectory = Directory.current;
try {
if (workingDirectory != null) {
Directory.current = workingDirectory;
}
final exitCode = await _commandRunner.run(args);
if (exitCode == ExitCode.success.code) {
return CallToolResult(
content: [TextContent(text: '"$toolName" completed successfully.')],
isError: false,
);
}
final message =
'"$toolName" failed with exit code $exitCode.\n'
'Command: $commandString';
stderr.writeln('[very_good_mcp] $message');
return CallToolResult(
content: [TextContent(text: message)],
isError: true,
);
} on UsageException catch (e) {
final message =
'"$toolName" usage error: ${e.message}\n'
'Command: $commandString';
stderr.writeln('[very_good_mcp] $message');
return CallToolResult(
content: [TextContent(text: message)],
isError: true,
);
} on Exception catch (e, stackTrace) {
final message =
'"$toolName" threw an exception: $e\n'
'Command: $commandString';
stderr
..writeln('[very_good_mcp] $message')
..writeln('[very_good_mcp] Stack trace: $stackTrace');
return CallToolResult(
content: [TextContent(text: message)],
isError: true,
);
} finally {
if (workingDirectory != null) {
Directory.current = previousDirectory;
}
}
}
}