Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 63 additions & 46 deletions lib/src/mcp/mcp_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:meta/meta.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:very_good_cli/src/command_runner.dart';
import 'package:very_good_cli/src/mcp/lock.dart';
import 'package:very_good_cli/src/mcp/structured_tool_error.dart';
import 'package:very_good_cli/src/version.dart';

/// {@template command_runner_builder}
Expand Down Expand Up @@ -41,10 +42,8 @@ final class VeryGoodMCPServer extends MCPServer with ToolsSupport {
/// {@macro very_good_mcp_server}
VeryGoodMCPServer({
required StreamChannel<String> channel,
CommandRunnerBuilder? commandRunnerBuilder,
}) : _commandRunnerBuilder =
commandRunnerBuilder ?? defaultCommandRunnerBuilder,
super.fromStreamChannel(
this._commandRunnerBuilder = defaultCommandRunnerBuilder,
}) : super.fromStreamChannel(
channel,
implementation: Implementation(
name: 'very_good_cli',
Expand Down Expand Up @@ -484,7 +483,7 @@ Only one value can be selected.
Future<CallToolResult> _handleCreate(CallToolRequest request) async {
final args = request.arguments ?? {};
final cliArgs = _parseCreate(args);
return _runToolCommand(cliArgs, toolName: 'create');
return _runToolCommand(cliArgs, toolName: 'create', requestArguments: args);
}

Future<CallToolResult> _handleTest(CallToolRequest request) async {
Expand All @@ -494,6 +493,7 @@ Only one value can be selected.
cliArgs,
toolName: 'test',
directory: args['directory'] as String?,
requestArguments: args,
);
}

Expand All @@ -504,6 +504,7 @@ Only one value can be selected.
cliArgs,
toolName: 'packages get',
directory: args['directory'] as String?,
requestArguments: args,
);
}

Expand All @@ -515,23 +516,22 @@ Only one value can be selected.
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,
);
return StructuredToolError(
toolName: 'packages check licenses',
reason:
'No check specified. Currently only "licenses" check is '
'supported. Set licenses=true to run license checks.',
failureType: ToolFailureType.validation,
attemptedArguments: args,
).toCallToolResult();
}

final cliArgs = _parsePackagesCheck(args);
return _runToolCommand(
cliArgs,
toolName: 'packages check licenses',
directory: args['directory'] as String?,
requestArguments: args,
);
}

Expand All @@ -556,6 +556,7 @@ Only one value can be selected.
List<String> args, {
required String toolName,
String? directory,
Map<String, Object?>? requestArguments,
}) {
return _lock.run(() async {
final commandString = 'very_good ${args.join(' ')}';
Expand All @@ -570,31 +571,34 @@ Only one value can be selected.
);
}

// Appends the captured command output (the real diagnostics) to a
// message, on every result path so partial output emitted before a throw
// is not lost. The buffer is populated whether the run returns or throws.
String withCapturedOutput(String message) {
// Builds a structured JSON failure result from [reason] and
// [failureType]. The captured command output is surfaced as
// `partialResults` so any diagnostics emitted before a failure or throw
// are preserved. A short human-readable summary is also logged to the
// real stderr (the stdio transport forbids non-JSON on stdout, so stderr
// is free for diagnostics).
CallToolResult errorResult(
String reason, {
required ToolFailureType failureType,
StackTrace? stackTrace,
}) {
final captured = sanitizeCommandOutput(output.toString()).trim();
if (captured.isEmpty) return message;
return '$message\n\nOutput:\n$captured';
}

// Builds a failure result from [reason] (the human-readable cause). The
// message is logged once to the real stderr (the stdio transport forbids
// only non-JSON on stdout, so stderr is free for diagnostics) and also
// surfaced — with any captured output — in the tool result, so the same
// text never has to be written twice. [commandString] is appended to keep
// the failure reproducible.
CallToolResult errorResult(String reason, {StackTrace? stackTrace}) {
final message = '"$toolName" $reason\nCommand: $commandString';
stderr.writeln('[very_good_mcp] ${message.replaceAll('\n', ' ')}');
stderr.writeln(
'[very_good_mcp] "$toolName" ${failureType.name} error: $reason '
'(command: $commandString)',
);
if (stackTrace != null) {
stderr.writeln('[very_good_mcp] Stack trace: $stackTrace');
}
return CallToolResult(
content: [TextContent(text: withCapturedOutput(message))],
isError: true,
);
return StructuredToolError(
toolName: toolName,
reason: reason,
failureType: failureType,
commandString: commandString,
directory: directory,
attemptedArguments: requestArguments,
capturedOutput: captured,
).toCallToolResult();
}

// Apply [directory] as the real working directory for the duration of
Expand All @@ -621,11 +625,21 @@ Only one value can be selected.
);
}

return errorResult('failed with exit code $exitCode.');
return errorResult(
'failed with exit code $exitCode.',
failureType: ToolFailureType.fromExitCode(exitCode),
);
} on UsageException catch (e) {
return errorResult('usage error: ${e.message}');
return errorResult(
'usage error: ${e.message}',
failureType: ToolFailureType.validation,
);
} on Exception catch (e, stackTrace) {
return errorResult('threw an exception: $e', stackTrace: stackTrace);
return errorResult(
'threw an exception: $e',
failureType: ToolFailureType.transient,
stackTrace: stackTrace,
);
} finally {
if (directory != null) Directory.current = previousDirectory;
}
Expand Down Expand Up @@ -697,11 +711,14 @@ class CapturingStdout implements Stdout {
bool get supportsAnsiEscapes => false;

@override
int get terminalColumns =>
throw const StdoutException('No terminal attached');
int get terminalColumns {
throw const StdoutException('No terminal attached');
}

@override
int get terminalLines => throw const StdoutException('No terminal attached');
int get terminalLines {
throw const StdoutException('No terminal attached');
}

@override
IOSink get nonBlocking => this;
Expand All @@ -728,9 +745,9 @@ String sanitizeCommandOutput(String raw) {
.replaceAll(_ansiEscape, '')
.replaceAll('\r\n', '\n')
.split('\n')
.map(
(line) =>
(line.contains('\r') ? line.split('\r').last : line).trimRight(),
)
.map((line) {
final output = line.contains('\r') ? line.split('\r').last : line;
return output.trimRight();
})
.join('\n');
}
174 changes: 174 additions & 0 deletions lib/src/mcp/structured_tool_error.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import 'dart:convert';

import 'package:dart_mcp/server.dart';
import 'package:mason/mason.dart' hide packageVersion;
import 'package:meta/meta.dart';

/// Classifies a tool failure into a bucket an agent loop can act on without
/// parsing free-form text.
///
/// * `validation` — the caller supplied bad input; retrying as-is won't help.
/// * `permission` — the process lacks filesystem or credential access.
/// * `transient` — an environment or infrastructure hiccup; retrying may
/// succeed.
/// * `business` — a domain rule was violated; the safest default for an
/// outcome that can't be attributed to the other three.
enum ToolFailureType {
/// A caller-supplied argument was invalid.
validation,

/// The process lacked permission to complete the action.
permission,

/// An environment or infrastructure hiccup; retrying may resolve it.
transient,

/// A domain rule was violated.
business;

/// Classifies an [exitCode] into a [ToolFailureType].
///
/// Codes follow the sysexits.h conventions surfaced by `package:io`'s
/// [ExitCode]; unknown codes fall back to [ToolFailureType.business], the
/// safest default for an outcome we can't attribute to a transient failure
/// or a bad input.
factory ToolFailureType.fromExitCode(int exitCode) {
if (exitCode == ExitCode.usage.code ||
exitCode == ExitCode.data.code ||
exitCode == ExitCode.noInput.code ||
exitCode == ExitCode.config.code) {
return ToolFailureType.validation;
}

if (exitCode == ExitCode.noPerm.code) {
return ToolFailureType.permission;
}

if (exitCode == ExitCode.unavailable.code ||
exitCode == ExitCode.tempFail.code ||
exitCode == ExitCode.ioError.code ||
exitCode == ExitCode.osError.code ||
exitCode == ExitCode.osFile.code ||
exitCode == ExitCode.cantCreate.code) {
return ToolFailureType.transient;
}

return ToolFailureType.business;
}
}

/// Suggests alternative approaches keyed on [failureType].
///
/// These are surfaced verbatim in the structured error payload so an agent
/// coordinator has recovery options attached to every failure without having
/// to reason about the failure type itself.
@visibleForTesting
List<String> alternativeApproachesFor(ToolFailureType failureType) {
const alternativeApproachesByFailureType = {
ToolFailureType.transient: [
'Retry the command; the failure may resolve on its own.',
'Check network and remote service availability, then retry.',
'If retries keep failing with the same error, switch approach.',
],
ToolFailureType.validation: [
'Correct any invalid tool arguments before retrying.',
'Consult the tool schema for accepted parameters and values.',
'Inspect the captured output for the field the CLI rejected.',
],
ToolFailureType.permission: [
'Ensure the process can read and write the target directory.',
'Retry after adjusting filesystem permissions or credentials.',
'Cannot be retried as-is without an authorization change.',
],
ToolFailureType.business: [
'Inspect the captured output for the specific rule reported.',
'Try an alternate subcommand, template, or configuration.',
'Escalate to the user if the constraint cannot be satisfied.',
],
};

return alternativeApproachesByFailureType[failureType]!;
}

/// {@template structured_tool_error}
/// A structured description of a tool failure, renderable as a
/// [CallToolResult].
///
/// Encapsulates the JSON payload shape agents rely on to pick a recovery
/// strategy:
///
/// * `status` — `partial_failure` if [capturedOutput] is non-empty, else
/// `failure`.
/// * `failureType` — the [ToolFailureType] name.
/// * `attemptedAction` — the tool name, the concrete CLI command that was
/// invoked (when known), the working directory (when set), and the raw
/// caller-supplied arguments.
/// * `reason` — the human-readable failure cause.
/// * `partialResults` — the sanitized captured command output, when any.
/// * `alternativeApproaches` — recovery suggestions from
/// [alternativeApproachesFor].
/// {@endtemplate}
class StructuredToolError {
/// {@macro structured_tool_error}
const StructuredToolError({
required this.toolName,
required this.reason,
required this.failureType,
this.commandString,
this.directory,
this.capturedOutput,
this.attemptedArguments,
});

/// The name of the tool that failed.
final String toolName;

/// The human-readable failure cause.
final String reason;

/// The bucket this failure falls into.
final ToolFailureType failureType;

/// The concrete CLI command that was invoked, when known.
final String? commandString;

/// The working directory the command ran in, when set.
final String? directory;

/// The sanitized captured command output, when any.
final String? capturedOutput;

/// The raw caller-supplied arguments.
final Map<String, Object?>? attemptedArguments;

bool get _hasPartialResults =>
capturedOutput != null && capturedOutput!.isNotEmpty;

/// The JSON payload describing this failure.
Map<String, Object?> toJson() {
final attemptedAction = <String, Object?>{
'tool': toolName,
'command': ?commandString,
'directory': ?directory,
if (attemptedArguments != null && attemptedArguments!.isNotEmpty)
'arguments': attemptedArguments,
};

return {
'status': _hasPartialResults ? 'partial_failure' : 'failure',
'failureType': failureType.name,
'attemptedAction': attemptedAction,
'reason': reason,
if (_hasPartialResults) 'partialResults': capturedOutput,
'alternativeApproaches': alternativeApproachesFor(failureType),
};
}

/// Renders this failure as a [CallToolResult] whose single text content is
/// the pretty-printed [toJson] payload.
CallToolResult toCallToolResult() {
final text = const JsonEncoder.withIndent(' ').convert(toJson());

return CallToolResult(content: [TextContent(text: text)], isError: true);
}
}
Loading
Loading