Skip to content

Commit a49591e

Browse files
ryzizubclaudemarcossevilla
authored
fix(mcp): surface command output and run tools in the requested directory (#1612)
* fix(mcp): surface command output and run tools in the requested directory The `test`, `packages_get`, and `packages_check_licenses` MCP tools previously returned only an exit code (e.g. 69) and discarded the command output, and appended `directory` as a positional argument instead of using it as the working directory. - Capture the in-process command's mason Logger output via an IOOverrides stdout/stderr redirect (with the Logger constructed inside the zone, since mason pins IOOverrides.current at construction) and return it in CallToolResult.content with isError, so failures are diagnosable. This also keeps non-JSON off the real stdout that carries the MCP JSON-RPC stream. - Apply `directory` as the real working directory for the run and restore it afterwards; remove the positional misuse. - Serialize tool runs so the process-global working-directory switch is safe under concurrent (pipelined) tool calls. - Ignore generated *.vm.json test artifacts. Closes #1611 Refs #1599, #1600 Refs VeryGoodOpenSource/vgv-ai-flutter-plugin#94 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): use matchers and expectLater in MCP tests Wrap bare-value expectations in `equals()` and assert async completion with `expectLater(..., completes)` instead of bare awaits, per review feedback on the MCP tool tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(mcp): use equals matcher for argument assertions Wrap the remaining bare-value expectations (captured CLI args lists and the registered-tools count) in matchers (`equals`/`hasLength`) so the MCP tests are consistent with the rest of the repo's test conventions, per review feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(mcp): drop redundant comments Remove two comments that restated the code: a logger-capture note that duplicated the _runToolCommand doc comment, and the inline narration of verifyNever/cwd assertions in the non-existent-directory test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Marcos Sevilla <31174242+marcossevilla@users.noreply.github.com>
1 parent 9ff29b8 commit a49591e

5 files changed

Lines changed: 704 additions & 160 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ doc/api/
1717
coverage/
1818
.test_optimizer.dart
1919
!bricks/test_optimizer/__brick__/test/.test_optimizer.dart
20+
*.vm.json
2021

2122
# Android studio and IntelliJ
2223
.idea

lib/src/mcp/lock.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import 'dart:async';
2+
3+
/// {@template lock}
4+
/// A first-in, first-out asynchronous mutex.
5+
///
6+
/// [run] executes its body only after every previously enqueued body has
7+
/// completed, so at most one body runs at a time. Unlike a one-deep lock
8+
/// (which only awaits a single pending run), this serializes correctly for any
9+
/// number of concurrent callers — each call chains onto the tail of the queue.
10+
/// {@endtemplate}
11+
class Lock {
12+
/// Tail of the queue: the future that completes when the most recently
13+
/// enqueued run finishes.
14+
Future<void> _tail = Future<void>.value();
15+
16+
/// Runs [body] once all previously enqueued runs have completed, and returns
17+
/// its result. A failing [body] does not break the queue — subsequent runs
18+
/// still proceed in order.
19+
Future<T> run<T>(Future<T> Function() body) {
20+
final previous = _tail;
21+
final completer = Completer<void>();
22+
_tail = completer.future;
23+
return previous.then((_) => body()).whenComplete(completer.complete);
24+
}
25+
}

lib/src/mcp/mcp_server.dart

Lines changed: 224 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
11
import '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

46
import 'package:args/command_runner.dart';
57
import 'package:dart_mcp/server.dart';
68
import 'package:mason/mason.dart' hide packageVersion;
9+
import 'package:meta/meta.dart';
710
import 'package:stream_channel/stream_channel.dart';
811
import 'package:very_good_cli/src/command_runner.dart';
12+
import 'package:very_good_cli/src/mcp/lock.dart';
913
import '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\nOutput:\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\nCommand: $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

Comments
 (0)