11import 'dart:async' ;
2- import 'dart:io' show Directory, stderr;
2+ import 'dart:convert' ;
3+ import 'dart:io'
4+ show Directory, IOOverrides, IOSink, Stdout, StdoutException, stderr;
35
46import 'package:args/command_runner.dart' ;
57import 'package:dart_mcp/server.dart' ;
68import 'package:mason/mason.dart' hide packageVersion;
9+ import 'package:meta/meta.dart' ;
710import 'package:stream_channel/stream_channel.dart' ;
811import 'package:very_good_cli/src/command_runner.dart' ;
12+ import 'package:very_good_cli/src/mcp/lock.dart' ;
913import 'package:very_good_cli/src/version.dart' ;
1014
15+ /// {@template command_runner_builder}
16+ /// Builds a [VeryGoodCommandRunner] bound to the provided [logger] .
17+ ///
18+ /// A builder (rather than a prebuilt runner) is required so the runner — and
19+ /// therefore its mason [Logger] — can be constructed *inside* the
20+ /// [IOOverrides] zone that redirects `stdout` /`stderr` . mason captures
21+ /// `IOOverrides.current` once, at [Logger] construction, so a runner built
22+ /// outside the zone would still write to the real process stdout and corrupt
23+ /// the MCP JSON-RPC stream.
24+ /// {@endtemplate}
25+ typedef CommandRunnerBuilder =
26+ VeryGoodCommandRunner Function ({required Logger logger});
27+
28+ /// The default [CommandRunnerBuilder] used when none is injected.
29+ @visibleForTesting
30+ VeryGoodCommandRunner defaultCommandRunnerBuilder ({required Logger logger}) =>
31+ VeryGoodCommandRunner (logger: logger);
32+
1133/// {@template very_good_mcp_server}
1234/// MCP Server for Very Good CLI.
1335///
@@ -19,10 +41,9 @@ final class VeryGoodMCPServer extends MCPServer with ToolsSupport {
1941 /// {@macro very_good_mcp_server}
2042 VeryGoodMCPServer ({
2143 required StreamChannel <String > channel,
22- Logger ? logger,
23- VeryGoodCommandRunner ? commandRunner,
24- }) : _commandRunner =
25- commandRunner ?? VeryGoodCommandRunner (logger: logger ?? Logger ()),
44+ CommandRunnerBuilder ? commandRunnerBuilder,
45+ }) : _commandRunnerBuilder =
46+ commandRunnerBuilder ?? defaultCommandRunnerBuilder,
2647 super .fromStreamChannel (
2748 channel,
2849 implementation: Implementation (
@@ -34,7 +55,16 @@ final class VeryGoodMCPServer extends MCPServer with ToolsSupport {
3455 'for creating and managing Dart/Flutter projects.' ,
3556 );
3657
37- final VeryGoodCommandRunner _commandRunner;
58+ /// {@macro command_runner_builder}
59+ final CommandRunnerBuilder _commandRunnerBuilder;
60+
61+ /// Serializes tool runs.
62+ ///
63+ /// [_runToolCommand] switches the process-global `Directory.current` , so tool
64+ /// runs must not overlap — the MCP transport can dispatch tool calls
65+ /// concurrently (pipelined requests), and overlapping runs would corrupt each
66+ /// other's working directory. The [Lock] keeps at most one run in flight.
67+ final _lock = Lock ();
3868
3969 @override
4070 FutureOr <InitializeResult > initialize (InitializeRequest request) async {
@@ -438,7 +468,7 @@ Only one value can be selected.
438468 return _runToolCommand (
439469 cliArgs,
440470 toolName: 'test' ,
441- workingDirectory : args['directory' ] as String ? ,
471+ directory : args['directory' ] as String ? ,
442472 );
443473 }
444474
@@ -448,7 +478,7 @@ Only one value can be selected.
448478 return _runToolCommand (
449479 cliArgs,
450480 toolName: 'packages get' ,
451- workingDirectory : args['directory' ] as String ? ,
481+ directory : args['directory' ] as String ? ,
452482 );
453483 }
454484
@@ -476,71 +506,206 @@ Only one value can be selected.
476506 return _runToolCommand (
477507 cliArgs,
478508 toolName: 'packages check licenses' ,
479- workingDirectory : args['directory' ] as String ? ,
509+ directory : args['directory' ] as String ? ,
480510 );
481511 }
482512
483- /// Runs a CLI command and returns a [CallToolResult] with descriptive
484- /// error messages including the command that was run and the exit code.
513+ /// Runs a CLI command in-process and returns a [CallToolResult] .
514+ ///
515+ /// The command is executed inside an [IOOverrides] zone that:
516+ ///
517+ /// * redirects `stdout` /`stderr` into a buffer, so the command's mason
518+ /// [Logger] output (test results, compile errors, ...) is captured and
519+ /// returned in the result instead of leaking onto the real process stdout,
520+ /// which is shared with the MCP JSON-RPC stream (the stdio transport
521+ /// requires the server MUST NOT write non-MCP content to stdout); and
522+ /// * sets the current directory to [directory] when provided, so in-process
523+ /// commands that resolve their target from `Directory.current` run in the
524+ /// right package (`directory` is the working directory, not a positional
525+ /// argument).
526+ ///
527+ /// The [Logger] is constructed *inside* the zone on purpose: mason captures
528+ /// `IOOverrides.current` at [Logger] construction time, so building it
529+ /// outside the zone would defeat the redirect.
485530 Future <CallToolResult > _runToolCommand (
486531 List <String > args, {
487532 required String toolName,
488- String ? workingDirectory,
489- }) async {
490- final commandString = 'very_good ${args .join (' ' )}' ;
491-
492- // The underlying CLI commands resolve their target package from
493- // `Directory.current` (and child processes inherit the process cwd), so a
494- // requested [workingDirectory] is applied by switching the current
495- // directory for the duration of the run and restoring it afterwards.
496- // Relative paths are resolved against the server's current directory.
497- final previousDirectory = Directory .current;
533+ String ? directory,
534+ }) {
535+ return _lock.run (() async {
536+ final commandString = 'very_good ${args .join (' ' )}' ;
537+ final output = StringBuffer ();
538+
539+ Future <T > runCaptured <T >(Future <T > Function (Logger logger) body) {
540+ final sink = CapturingStdout (output);
541+ return IOOverrides .runZoned (
542+ () => body (Logger ()),
543+ stdout: () => sink,
544+ stderr: () => sink,
545+ );
546+ }
498547
499- try {
500- if (workingDirectory != null ) {
501- Directory .current = workingDirectory;
548+ // Appends the captured command output (the real diagnostics) to a
549+ // message, on every result path so partial output emitted before a throw
550+ // is not lost. The buffer is populated whether the run returns or throws.
551+ String withCapturedOutput (String message) {
552+ final captured = sanitizeCommandOutput (output.toString ()).trim ();
553+ if (captured.isEmpty) return message;
554+ return '$message \n\n Output:\n $captured ' ;
502555 }
503- final exitCode = await _commandRunner.run (args);
504556
505- if (exitCode == ExitCode .success.code) {
557+ // Builds a failure result from [reason] (the human-readable cause). The
558+ // message is logged once to the real stderr (the stdio transport forbids
559+ // only non-JSON on stdout, so stderr is free for diagnostics) and also
560+ // surfaced — with any captured output — in the tool result, so the same
561+ // text never has to be written twice. [commandString] is appended to keep
562+ // the failure reproducible.
563+ CallToolResult errorResult (String reason, {StackTrace ? stackTrace}) {
564+ final message = '"$toolName " $reason \n Command: $commandString ' ;
565+ stderr.writeln ('[very_good_mcp] ${message .replaceAll ('\n ' , ' ' )}' );
566+ if (stackTrace != null ) {
567+ stderr.writeln ('[very_good_mcp] Stack trace: $stackTrace ' );
568+ }
506569 return CallToolResult (
507- content: [TextContent (text: '"$ toolName " completed successfully.' )],
508- isError: false ,
570+ content: [TextContent (text: withCapturedOutput (message) )],
571+ isError: true ,
509572 );
510573 }
511574
512- final message =
513- '"$ toolName " failed with exit code $ exitCode . \n '
514- 'Command: $ commandString ' ;
515- stderr. writeln ( '[very_good_mcp] $ message ' );
516- return CallToolResult (
517- content : [ TextContent (text : message)],
518- isError : true ,
519- );
520- } on UsageException catch (e) {
521- final message =
522- '"$ toolName " usage error: ${ e . message } \n '
523- 'Command: $ commandString ' ;
524- stderr. writeln ( '[very_good_mcp] $ message ' );
525- return CallToolResult (
526- content : [ TextContent (text : message)],
527- isError : true ,
528- );
529- } on Exception catch (e, stackTrace) {
530- final message =
531- '"$ toolName " threw an exception: $ e \n '
532- 'Command: $ commandString ' ;
533- stderr
534- .. writeln ( '[very_good_mcp] $ message ' )
535- .. writeln ( '[very_good_mcp] Stack trace: $ stackTrace ' );
536- return CallToolResult (
537- content : [ TextContent (text : message)],
538- isError : true ,
539- );
540- } finally {
541- if (workingDirectory != null ) {
542- Directory .current = previousDirectory;
575+ // Apply [directory] as the real working directory for the duration of
576+ // the run, restoring it afterwards. The underlying commands resolve their
577+ // target package from the process current directory and spawn
578+ // subprocesses with it, so it must be the real cwd (not just an
579+ // IOOverrides override, which subprocesses do not honor).
580+ final previousDirectory = Directory .current;
581+
582+ try {
583+ if (directory != null ) Directory .current = directory;
584+ final exitCode = await runCaptured (
585+ (logger) => _commandRunnerBuilder (logger : logger). run (args),
586+ ) ;
587+
588+ if (exitCode == ExitCode .success.code) {
589+ final captured = sanitizeCommandOutput (output. toString ()). trim ();
590+ return CallToolResult (
591+ content : [
592+ TextContent (text : '"$ toolName " completed successfully.' ),
593+ if (captured.isNotEmpty) TextContent (text : captured),
594+ ],
595+ isError : false ,
596+ );
597+ }
598+
599+ return errorResult ( 'failed with exit code $ exitCode .' );
600+ } on UsageException catch (e) {
601+ return errorResult ( 'usage error: ${ e . message }' );
602+ } on Exception catch (e, stackTrace) {
603+ return errorResult ( 'threw an exception: $ e ' , stackTrace : stackTrace);
604+ } finally {
605+ if (directory != null ) Directory .current = previousDirectory;
543606 }
607+ });
608+ }
609+ }
610+
611+ /// A [Stdout] that captures everything written to it into a [StringBuffer]
612+ /// instead of the real process stdout/stderr.
613+ ///
614+ /// Used to redirect a command's in-process [Logger] output (which mason routes
615+ /// through `stdout` /`stderr` , including progress spinners) away from the real
616+ /// stdout shared with the MCP JSON-RPC stream. It reports no terminal so mason
617+ /// emits plain, animation-free lines.
618+ @visibleForTesting
619+ class CapturingStdout implements Stdout {
620+ /// Creates a [CapturingStdout] that appends all writes to [_buffer] .
621+ CapturingStdout (this ._buffer);
622+
623+ final StringBuffer _buffer;
624+
625+ @override
626+ Encoding encoding = utf8;
627+
628+ @override
629+ String lineTerminator = '\n ' ;
630+
631+ @override
632+ void write (Object ? object) => _buffer.write (object ?? 'null' );
633+
634+ @override
635+ void writeln ([Object ? object = '' ]) => _buffer.writeln (object ?? '' );
636+
637+ @override
638+ void writeAll (Iterable <dynamic > objects, [String separator = '' ]) =>
639+ _buffer.writeAll (objects, separator);
640+
641+ @override
642+ void writeCharCode (int charCode) => _buffer.writeCharCode (charCode);
643+
644+ @override
645+ void add (List <int > data) {
646+ try {
647+ _buffer.write (encoding.decode (data));
648+ } on FormatException {
649+ _buffer.write (String .fromCharCodes (data));
544650 }
545651 }
652+
653+ @override
654+ void addError (Object error, [StackTrace ? stackTrace]) {}
655+
656+ @override
657+ Future <void > addStream (Stream <List <int >> stream) => stream.forEach (add);
658+
659+ @override
660+ Future <void > flush () async {}
661+
662+ @override
663+ Future <void > close () async {}
664+
665+ @override
666+ Future <void > get done => Future <void >.value ();
667+
668+ @override
669+ bool get hasTerminal => false ;
670+
671+ @override
672+ bool get supportsAnsiEscapes => false ;
673+
674+ @override
675+ int get terminalColumns =>
676+ throw const StdoutException ('No terminal attached' );
677+
678+ @override
679+ int get terminalLines => throw const StdoutException ('No terminal attached' );
680+
681+ @override
682+ IOSink get nonBlocking => this ;
683+ }
684+
685+ /// Matches a CSI ANSI escape sequence (colors, cursor moves, line erases).
686+ final _ansiEscape = RegExp (r'\x1B\[[0-?]*[ -/]*[@-~]' );
687+
688+ /// Renders raw captured command output as plain text for a tool result.
689+ ///
690+ /// In-process commands (and the test subprocesses they reformat) animate
691+ /// progress with ANSI escape sequences and carriage returns: a spinner redraws
692+ /// a single line in place with `\r` and erases it with `\x1B[2K` . A terminal
693+ /// resolves those to clean lines, but the raw bytes surfaced to an MCP client
694+ /// collapse into one run-on line. This reproduces the terminal's settled view:
695+ ///
696+ /// * strips ANSI escape sequences;
697+ /// * normalizes `\r\n` to `\n` ; and
698+ /// * collapses carriage-return redraws to the text after the last `\r` on each
699+ /// line (the final state the user would see), trimming trailing padding.
700+ @visibleForTesting
701+ String sanitizeCommandOutput (String raw) {
702+ return raw
703+ .replaceAll (_ansiEscape, '' )
704+ .replaceAll ('\r\n ' , '\n ' )
705+ .split ('\n ' )
706+ .map (
707+ (line) =>
708+ (line.contains ('\r ' ) ? line.split ('\r ' ).last : line).trimRight (),
709+ )
710+ .join ('\n ' );
546711}
0 commit comments