Expose infra commands as MCP tools
This commit is contained in:
parent
f191a276a8
commit
69fa044e5b
6 changed files with 810 additions and 14 deletions
8
.project/kanban/done/DEW-0037.md
Normal file
8
.project/kanban/done/DEW-0037.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
id: DEW-0037
|
||||
title: Expose infra operations through MCP tools
|
||||
type: task
|
||||
created: 2026-05-05T04:45:19.903814Z
|
||||
---
|
||||
|
||||
Add Dew MCP tools for every dew infra CLI path, including service discovery, show, validation, configure/init subpaths, lifecycle operations, logs, and delete.
|
||||
|
|
@ -158,6 +158,9 @@ class CommandRegistry {
|
|||
final tools = <McpTool>[];
|
||||
void collect(Command<void> cmd) {
|
||||
if (cmd is DewToolCommand) tools.add(cmd.toMcpTool());
|
||||
if (cmd is McpToolProvider) {
|
||||
tools.addAll((cmd as McpToolProvider).tools);
|
||||
}
|
||||
for (final sub in cmd.subcommands.values) {
|
||||
collect(sub);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,12 @@ class InitCommand extends Command<void> {
|
|||
final List<DewInitHook> _hooks;
|
||||
final FileSystem _fs;
|
||||
|
||||
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()})
|
||||
: _fs = fs {
|
||||
InitCommand(
|
||||
List<DewInitHook> hooks, {
|
||||
FileSystem fs = const LocalFileSystem(),
|
||||
}) : this._(hooks, fs);
|
||||
|
||||
InitCommand._(this._hooks, this._fs) {
|
||||
argParser
|
||||
..addOption(
|
||||
'path',
|
||||
|
|
|
|||
|
|
@ -133,6 +133,33 @@ abstract class _InfraSubcommand extends DewCommand {
|
|||
);
|
||||
}
|
||||
|
||||
Future<_InfraEnvironment> _toolEnvironment(Map<String, dynamic> args) async {
|
||||
final projectArg = _stringArg(args, 'project');
|
||||
final projectDirectory = projectArg == null
|
||||
? null
|
||||
: fs.directory(_resolveFromCwd(projectArg));
|
||||
final projectContext = await ProjectContext.find(
|
||||
fs: fs,
|
||||
from: projectDirectory,
|
||||
);
|
||||
final infraArg =
|
||||
_stringArg(args, 'infra_dir') ?? _stringArg(args, 'infra-dir');
|
||||
final infraDir = infraArg == null
|
||||
? p.join(projectContext.root, '.project', 'infrastructure')
|
||||
: _resolveProjectPath(projectContext.root, infraArg);
|
||||
return _InfraEnvironment(
|
||||
projectContext: projectContext,
|
||||
options: null,
|
||||
repository: InfraRepository(infraDir: infraDir, fs: fs),
|
||||
validator: InfraValidator(fs: fs),
|
||||
runtimeRegistry: runtimeRegistry,
|
||||
scope: InfraScope.parse(_stringArg(args, 'scope') ?? 'user'),
|
||||
json: true,
|
||||
dryRun: _boolArg(args, 'dry_run') ?? _boolArg(args, 'dry-run') ?? false,
|
||||
yes: _boolArg(args, 'yes') ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
ArgResults _infraOptions() => (parent as InfraCommand).argResults!;
|
||||
|
||||
String _requiredServiceArg([String? usage]) {
|
||||
|
|
@ -168,7 +195,7 @@ class _InfraEnvironment {
|
|||
});
|
||||
|
||||
final ProjectContext projectContext;
|
||||
final ArgResults options;
|
||||
final ArgResults? options;
|
||||
final InfraRepository repository;
|
||||
final InfraValidator validator;
|
||||
final ContainerRuntimeRegistry runtimeRegistry;
|
||||
|
|
@ -181,7 +208,116 @@ class _InfraEnvironment {
|
|||
runtimeRegistry.forKind(manifest.runtime);
|
||||
}
|
||||
|
||||
class InfraListCommand extends _InfraSubcommand {
|
||||
const _toolJsonEncoder = JsonEncoder.withIndent(' ');
|
||||
|
||||
Map<String, dynamic> _infraToolSchema({
|
||||
Map<String, dynamic> properties = const {},
|
||||
List<String> required = const [],
|
||||
bool includeDryRun = false,
|
||||
bool includeYes = false,
|
||||
}) {
|
||||
final schemaProperties = <String, dynamic>{
|
||||
'project': {
|
||||
'type': 'string',
|
||||
'description':
|
||||
'Project root. Defaults to walking upward until .project/dew.yaml is found.',
|
||||
},
|
||||
'infra_dir': {
|
||||
'type': 'string',
|
||||
'description':
|
||||
'Infrastructure root. Defaults to .project/infrastructure.',
|
||||
},
|
||||
'scope': {
|
||||
'type': 'string',
|
||||
'enum': ['user', 'system'],
|
||||
'default': 'user',
|
||||
'description': 'Quadlet/systemd scope.',
|
||||
},
|
||||
if (includeDryRun)
|
||||
'dry_run': {
|
||||
'type': 'boolean',
|
||||
'description': 'Return intended actions without applying them.',
|
||||
},
|
||||
if (includeYes)
|
||||
'yes': {
|
||||
'type': 'boolean',
|
||||
'description': 'Skip confirmation for destructive operations.',
|
||||
},
|
||||
...properties,
|
||||
};
|
||||
return {
|
||||
'type': 'object',
|
||||
'properties': schemaProperties,
|
||||
if (required.isNotEmpty) 'required': required,
|
||||
};
|
||||
}
|
||||
|
||||
String? _stringArg(Map<String, dynamic> args, String key) {
|
||||
final value = args[key];
|
||||
if (value == null) return null;
|
||||
final text = '$value';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
String _requiredStringArg(Map<String, dynamic> args, String key) {
|
||||
final value = _stringArg(args, key);
|
||||
if (value == null) throw ArgumentError('Missing "$key".');
|
||||
return value;
|
||||
}
|
||||
|
||||
bool? _boolArg(Map<String, dynamic> args, String key) {
|
||||
final value = args[key];
|
||||
if (value == null) return null;
|
||||
if (value is bool) return value;
|
||||
if (value is String) {
|
||||
return switch (value.toLowerCase()) {
|
||||
'true' => true,
|
||||
'false' => false,
|
||||
_ => throw ArgumentError('"$key" must be a boolean.'),
|
||||
};
|
||||
}
|
||||
throw ArgumentError('"$key" must be a boolean.');
|
||||
}
|
||||
|
||||
int _intArg(Map<String, dynamic> args, String key, int defaultValue) {
|
||||
final value = args[key];
|
||||
if (value == null) return defaultValue;
|
||||
if (value is int) return value;
|
||||
final parsed = int.tryParse('$value');
|
||||
if (parsed == null) throw ArgumentError('"$key" must be an integer.');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
List<String> _stringListArg(Map<String, dynamic> args, String key) {
|
||||
final value = args[key];
|
||||
if (value == null) return const [];
|
||||
if (value is String) return value.isEmpty ? const [] : [value];
|
||||
if (value is List) return value.map((item) => '$item').toList();
|
||||
throw ArgumentError('"$key" must be a string or list of strings.');
|
||||
}
|
||||
|
||||
dynamic _normalizeJsonValue(Object? value) {
|
||||
if (value is Map) {
|
||||
return value.map(
|
||||
(key, value) => MapEntry('$key', _normalizeJsonValue(value)),
|
||||
);
|
||||
}
|
||||
if (value is List) return value.map(_normalizeJsonValue).toList();
|
||||
return value;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _objectArg(Map<String, dynamic> args, String key) {
|
||||
final value = args[key];
|
||||
if (value == null) return <String, dynamic>{};
|
||||
if (value is! Map) throw ArgumentError('"$key" must be an object.');
|
||||
return value.map(
|
||||
(key, value) => MapEntry('$key', _normalizeJsonValue(value)),
|
||||
);
|
||||
}
|
||||
|
||||
String _encodeToolResult(Object? value) => _toolJsonEncoder.convert(value);
|
||||
|
||||
class InfraListCommand extends _InfraSubcommand with DewToolCommand {
|
||||
InfraListCommand({required super.fs, required super.runtimeRegistry});
|
||||
|
||||
@override
|
||||
|
|
@ -190,6 +326,21 @@ class InfraListCommand extends _InfraSubcommand {
|
|||
@override
|
||||
final String description = 'List infrastructure services.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_list_services';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema();
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifests = await env.repository.list();
|
||||
return _encodeToolResult(
|
||||
manifests.map((manifest) => manifest.toJson()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final env = await _environment();
|
||||
|
|
@ -210,7 +361,7 @@ class InfraListCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraShowCommand extends _InfraSubcommand {
|
||||
class InfraShowCommand extends _InfraSubcommand with DewToolCommand {
|
||||
InfraShowCommand({required super.fs, required super.runtimeRegistry});
|
||||
|
||||
@override
|
||||
|
|
@ -219,6 +370,39 @@ class InfraShowCommand extends _InfraSubcommand {
|
|||
@override
|
||||
final String description = 'Show service manifest and runtime details.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_show_service';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
final runtime = env.runtimeFor(manifest);
|
||||
final installed = await runtime.isInstalled(manifest, env.scope);
|
||||
return _encodeToolResult({
|
||||
...manifest.toJson(),
|
||||
'installed': installed,
|
||||
'install_target': quadletSearchPath(
|
||||
env.scope,
|
||||
environment: io.Platform.environment,
|
||||
),
|
||||
'scope': env.scope.name,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final service = _requiredServiceArg('Usage: dew infra show <service>.');
|
||||
|
|
@ -246,7 +430,7 @@ class InfraShowCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraValidateCommand extends _InfraSubcommand {
|
||||
class InfraValidateCommand extends _InfraSubcommand with DewToolCommand {
|
||||
InfraValidateCommand({required super.fs, required super.runtimeRegistry}) {
|
||||
argParser.addFlag('all', negatable: false, help: 'Validate all services.');
|
||||
}
|
||||
|
|
@ -257,6 +441,37 @@ class InfraValidateCommand extends _InfraSubcommand {
|
|||
@override
|
||||
final String description = 'Validate service manifests and referenced files.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_validate_services';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Service id to validate. Defaults to all services.',
|
||||
},
|
||||
'all': {'type': 'boolean', 'description': 'Validate all services.'},
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final service = _stringArg(args, 'service');
|
||||
final manifests = service == null
|
||||
? await env.repository.list()
|
||||
: [await env.repository.get(service)];
|
||||
final issues = <InfraValidationIssue>[];
|
||||
for (final manifest in manifests) {
|
||||
issues.addAll(await env.validator.validate(manifest));
|
||||
}
|
||||
return _encodeToolResult({
|
||||
'valid': issues.isEmpty,
|
||||
'issues': issues.map((issue) => issue.toJson()).toList(),
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final env = await _environment();
|
||||
|
|
@ -290,7 +505,9 @@ class InfraValidateCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraConfigureCommand extends _InfraSubcommand {
|
||||
class InfraConfigureCommand extends _InfraSubcommand
|
||||
with DewToolCommand
|
||||
implements McpToolProvider {
|
||||
InfraConfigureCommand({required super.fs, required super.runtimeRegistry}) {
|
||||
argParser
|
||||
..addOption('file', help: 'JSON configuration file for apply.')
|
||||
|
|
@ -304,6 +521,138 @@ class InfraConfigureCommand extends _InfraSubcommand {
|
|||
final String description =
|
||||
'Inspect or apply service configuration schema values.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_configure_service';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
);
|
||||
|
||||
@override
|
||||
List<McpTool> get tools => [
|
||||
McpTool(
|
||||
name: 'infra_configure_schema',
|
||||
description: 'Show a service configuration JSON Schema.',
|
||||
inputSchema: _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
),
|
||||
handler: _schemaAsTool,
|
||||
),
|
||||
McpTool(
|
||||
name: 'infra_configure_show',
|
||||
description: 'Show the active service configuration payload.',
|
||||
inputSchema: _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
),
|
||||
handler: _showAsTool,
|
||||
),
|
||||
McpTool(
|
||||
name: 'infra_configure_apply',
|
||||
description: 'Apply service configuration values.',
|
||||
inputSchema: _infraToolSchema(
|
||||
includeDryRun: true,
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
'file': {
|
||||
'type': 'string',
|
||||
'description': 'JSON configuration file for apply.',
|
||||
},
|
||||
'set': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Dotted key=value assignments for apply.',
|
||||
},
|
||||
'values': {
|
||||
'type': 'object',
|
||||
'description':
|
||||
'Configuration object merged before set assignments.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
),
|
||||
handler: _applyAsTool,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
throw ArgumentError(
|
||||
'Interactive schema editor is not implemented yet for ${manifest.id}. '
|
||||
'Use infra_configure_schema, infra_configure_show, or '
|
||||
'infra_configure_apply.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _schemaAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
return _encodeToolResult(
|
||||
await _readSchemaForTool(
|
||||
env,
|
||||
serviceId: manifest.id,
|
||||
schemaPath: manifest.configureSchemaPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _showAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
return _encodeToolResult(
|
||||
await _readPayloadForTool(
|
||||
env,
|
||||
serviceId: manifest.id,
|
||||
path: manifest.activeConfigurePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _applyAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
return _encodeToolResult(
|
||||
await _applyPayloadForTool(
|
||||
env,
|
||||
manifest: manifest,
|
||||
args: args,
|
||||
schemaPath: manifest.configureSchemaPath,
|
||||
outputPath: manifest.activeConfigurePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final rest = argResults?.rest ?? const [];
|
||||
|
|
@ -337,7 +686,9 @@ class InfraConfigureCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraInitCommand extends _InfraSubcommand {
|
||||
class InfraInitCommand extends _InfraSubcommand
|
||||
with DewToolCommand
|
||||
implements McpToolProvider {
|
||||
InfraInitCommand({required super.fs, required super.runtimeRegistry}) {
|
||||
argParser
|
||||
..addOption('file', help: 'JSON initialization file for run.')
|
||||
|
|
@ -350,6 +701,109 @@ class InfraInitCommand extends _InfraSubcommand {
|
|||
@override
|
||||
final String description = 'Inspect or run service initialization options.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_init_service';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
);
|
||||
|
||||
@override
|
||||
List<McpTool> get tools => [
|
||||
McpTool(
|
||||
name: 'infra_init_schema',
|
||||
description: 'Show a service initialization JSON Schema.',
|
||||
inputSchema: _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
),
|
||||
handler: _schemaAsTool,
|
||||
),
|
||||
McpTool(
|
||||
name: 'infra_init_run',
|
||||
description: 'Run service initialization by writing an init payload.',
|
||||
inputSchema: _infraToolSchema(
|
||||
includeDryRun: true,
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
'file': {
|
||||
'type': 'string',
|
||||
'description': 'JSON initialization file for run.',
|
||||
},
|
||||
'set': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Dotted key=value assignments for run.',
|
||||
},
|
||||
'values': {
|
||||
'type': 'object',
|
||||
'description':
|
||||
'Initialization object merged before set assignments.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
),
|
||||
handler: _runAsTool,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
throw ArgumentError(
|
||||
'Interactive schema editor is not implemented yet for ${manifest.id}. '
|
||||
'Use infra_init_schema or infra_init_run.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _schemaAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
return _encodeToolResult(
|
||||
await _readSchemaForTool(
|
||||
env,
|
||||
serviceId: manifest.id,
|
||||
schemaPath: manifest.initSchemaPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _runAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
return _encodeToolResult(
|
||||
await _applyPayloadForTool(
|
||||
env,
|
||||
manifest: manifest,
|
||||
args: args,
|
||||
schemaPath: manifest.initSchemaPath,
|
||||
outputPath: manifest.activeInitPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final rest = argResults?.rest ?? const [];
|
||||
|
|
@ -379,7 +833,7 @@ class InfraInitCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraRuntimeCommand extends _InfraSubcommand {
|
||||
class InfraRuntimeCommand extends _InfraSubcommand with DewToolCommand {
|
||||
InfraRuntimeCommand(
|
||||
this.name, {
|
||||
required super.fs,
|
||||
|
|
@ -402,6 +856,39 @@ class InfraRuntimeCommand extends _InfraSubcommand {
|
|||
_ => 'Manage infrastructure services.',
|
||||
};
|
||||
|
||||
@override
|
||||
String get toolName => switch (name) {
|
||||
'install' => 'infra_install_service',
|
||||
'uninstall' => 'infra_uninstall_service',
|
||||
'up' => 'infra_up_service',
|
||||
'down' => 'infra_down_service',
|
||||
'restart' => 'infra_restart_service',
|
||||
'status' => 'infra_status_service',
|
||||
_ => throw StateError('Unknown runtime command $name.'),
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
includeDryRun: name != 'status',
|
||||
properties: {
|
||||
'service': {'type': 'string', 'description': 'Service id to manage.'},
|
||||
'all': {'type': 'boolean', 'description': 'Apply to all services.'},
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifests = await _targetServicesFromTool(env, args);
|
||||
final results = <Map<String, Object?>>[];
|
||||
for (final manifest in manifests) {
|
||||
final runtime = env.runtimeFor(manifest);
|
||||
final result = await _runRuntimeCommand(env, manifest, runtime);
|
||||
results.add({'service': manifest.id, ...result.toJson()});
|
||||
}
|
||||
return _encodeToolResult(results);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final env = await _environment();
|
||||
|
|
@ -458,7 +945,7 @@ class InfraRuntimeCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraLogsCommand extends _InfraSubcommand {
|
||||
class InfraLogsCommand extends _InfraSubcommand with DewToolCommand {
|
||||
InfraLogsCommand({required super.fs, required super.runtimeRegistry}) {
|
||||
argParser
|
||||
..addFlag('follow', abbr: 'f', negatable: false, help: 'Follow logs.')
|
||||
|
|
@ -471,6 +958,52 @@ class InfraLogsCommand extends _InfraSubcommand {
|
|||
@override
|
||||
final String description = 'Show service logs.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_logs';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Infrastructure service id.',
|
||||
},
|
||||
'follow': {
|
||||
'type': 'boolean',
|
||||
'description':
|
||||
'CLI-compatible flag. MCP calls reject true because it does not terminate.',
|
||||
},
|
||||
'lines': {
|
||||
'type': 'integer',
|
||||
'default': 200,
|
||||
'description': 'Number of log lines.',
|
||||
},
|
||||
},
|
||||
required: ['service'],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
if (_boolArg(args, 'follow') ?? false) {
|
||||
throw ArgumentError(
|
||||
'logs follow mode does not terminate and is not supported through MCP.',
|
||||
);
|
||||
}
|
||||
final env = await _toolEnvironment(args);
|
||||
final manifest = await env.repository.get(
|
||||
_requiredStringArg(args, 'service'),
|
||||
);
|
||||
final result = await env
|
||||
.runtimeFor(manifest)
|
||||
.logs(
|
||||
manifest,
|
||||
scope: env.scope,
|
||||
follow: false,
|
||||
lines: _intArg(args, 'lines', 200),
|
||||
);
|
||||
return _encodeToolResult({'service': manifest.id, ...result.toJson()});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final env = await _environment();
|
||||
|
|
@ -493,7 +1026,7 @@ class InfraLogsCommand extends _InfraSubcommand {
|
|||
}
|
||||
}
|
||||
|
||||
class InfraDeleteCommand extends _InfraSubcommand {
|
||||
class InfraDeleteCommand extends _InfraSubcommand with DewToolCommand {
|
||||
InfraDeleteCommand({required super.fs, required super.runtimeRegistry}) {
|
||||
argParser
|
||||
..addFlag(
|
||||
|
|
@ -515,6 +1048,54 @@ class InfraDeleteCommand extends _InfraSubcommand {
|
|||
@override
|
||||
final String description = 'Delete service runtime artifacts.';
|
||||
|
||||
@override
|
||||
final String toolName = 'infra_delete_service';
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get toolInputSchema => _infraToolSchema(
|
||||
includeDryRun: true,
|
||||
includeYes: true,
|
||||
properties: {
|
||||
'service': {
|
||||
'type': 'string',
|
||||
'description': 'Service id to delete artifacts for.',
|
||||
},
|
||||
'all': {'type': 'boolean', 'description': 'Apply to all services.'},
|
||||
'container': {
|
||||
'type': 'boolean',
|
||||
'description': 'Delete named container runtime artifacts.',
|
||||
},
|
||||
'data': {
|
||||
'type': 'boolean',
|
||||
'description': 'Delete service data artifacts. Requires yes=true.',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||
final env = await _toolEnvironment(args);
|
||||
final deleteData = _boolArg(args, 'data') ?? false;
|
||||
if (deleteData && !env.yes) {
|
||||
throw ArgumentError('data=true requires yes=true.');
|
||||
}
|
||||
final manifests = await _targetServicesFromTool(env, args);
|
||||
final results = <Map<String, Object?>>[];
|
||||
for (final manifest in manifests) {
|
||||
final result = await env
|
||||
.runtimeFor(manifest)
|
||||
.delete(
|
||||
manifest,
|
||||
scope: env.scope,
|
||||
deleteContainer: _boolArg(args, 'container') ?? false,
|
||||
deleteData: deleteData,
|
||||
dryRun: env.dryRun,
|
||||
);
|
||||
results.add({'service': manifest.id, ...result.toJson()});
|
||||
}
|
||||
return _encodeToolResult(results);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> run() async {
|
||||
final env = await _environment();
|
||||
|
|
@ -545,8 +1126,8 @@ Future<List<InfraServiceManifest>> _targetServices(
|
|||
_InfraEnvironment env, {
|
||||
required bool allowAll,
|
||||
}) async {
|
||||
final all = (env.options.command?['all'] as bool?) ?? false;
|
||||
final rest = env.options.command?.rest ?? const <String>[];
|
||||
final all = (env.options?.command?['all'] as bool?) ?? false;
|
||||
final rest = env.options?.command?.rest ?? const <String>[];
|
||||
if (all) return env.repository.list();
|
||||
if (rest.isEmpty) {
|
||||
throw UsageException('Missing service. Use a service id or --all.', '');
|
||||
|
|
@ -554,6 +1135,19 @@ Future<List<InfraServiceManifest>> _targetServices(
|
|||
return [await env.repository.get(rest.first)];
|
||||
}
|
||||
|
||||
Future<List<InfraServiceManifest>> _targetServicesFromTool(
|
||||
_InfraEnvironment env,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final all = _boolArg(args, 'all') ?? false;
|
||||
final service = _stringArg(args, 'service');
|
||||
if (all) return env.repository.list();
|
||||
if (service == null) {
|
||||
throw ArgumentError('Missing "service". Pass a service id or all=true.');
|
||||
}
|
||||
return [await env.repository.get(service)];
|
||||
}
|
||||
|
||||
Future<void> _printSchema(_InfraEnvironment env, String? schemaPath) async {
|
||||
if (schemaPath == null) throw ArgumentError('No schema path is declared.');
|
||||
final file = env.repository.fs.file(schemaPath);
|
||||
|
|
@ -582,7 +1176,10 @@ Future<void> _applyPayload(
|
|||
required String outputPath,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{};
|
||||
final command = env.options.command!;
|
||||
final command = env.options?.command;
|
||||
if (command == null) {
|
||||
throw StateError('CLI apply payload requires parsed command options.');
|
||||
}
|
||||
final filePath = command['file'] as String?;
|
||||
if (filePath != null) {
|
||||
payload.addAll(_readJsonObject(env, _resolveAgainstProject(env, filePath)));
|
||||
|
|
@ -605,6 +1202,79 @@ Future<void> _applyPayload(
|
|||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readSchemaForTool(
|
||||
_InfraEnvironment env, {
|
||||
required String serviceId,
|
||||
required String? schemaPath,
|
||||
}) async {
|
||||
if (schemaPath == null) throw ArgumentError('No schema path is declared.');
|
||||
final file = env.repository.fs.file(schemaPath);
|
||||
if (!await file.exists()) {
|
||||
throw ArgumentError('Schema not found: $schemaPath');
|
||||
}
|
||||
return {
|
||||
'service': serviceId,
|
||||
'path': schemaPath,
|
||||
'schema': _normalizeJsonValue(jsonDecode(await file.readAsString())),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readPayloadForTool(
|
||||
_InfraEnvironment env, {
|
||||
required String serviceId,
|
||||
required String path,
|
||||
}) async {
|
||||
final file = env.repository.fs.file(path);
|
||||
if (!await file.exists()) {
|
||||
return {
|
||||
'service': serviceId,
|
||||
'path': path,
|
||||
'exists': false,
|
||||
'payload': <String, Object?>{},
|
||||
};
|
||||
}
|
||||
final decoded = jsonDecode(await file.readAsString());
|
||||
if (decoded is! Map) {
|
||||
throw FormatException('Expected JSON object in $path.');
|
||||
}
|
||||
return {
|
||||
'service': serviceId,
|
||||
'path': path,
|
||||
'exists': true,
|
||||
'payload': _normalizeJsonValue(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _applyPayloadForTool(
|
||||
_InfraEnvironment env, {
|
||||
required InfraServiceManifest manifest,
|
||||
required Map<String, dynamic> args,
|
||||
required String? schemaPath,
|
||||
required String outputPath,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{};
|
||||
final filePath = _stringArg(args, 'file');
|
||||
if (filePath != null) {
|
||||
payload.addAll(_readJsonObject(env, _resolveAgainstProject(env, filePath)));
|
||||
}
|
||||
payload.addAll(_objectArg(args, 'values'));
|
||||
for (final assignment in _stringListArg(args, 'set')) {
|
||||
_applyAssignment(payload, assignment);
|
||||
}
|
||||
await _validatePayload(env, schemaPath, payload);
|
||||
if (!env.dryRun) {
|
||||
final file = env.repository.fs.file(outputPath);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(_toolJsonEncoder.convert(payload));
|
||||
}
|
||||
return {
|
||||
'service': manifest.id,
|
||||
'path': outputPath,
|
||||
'dry_run': env.dryRun,
|
||||
'config': payload,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _readJsonObject(_InfraEnvironment env, String path) {
|
||||
final file = env.repository.fs.file(path);
|
||||
final decoded = jsonDecode(file.readAsStringSync());
|
||||
|
|
|
|||
|
|
@ -41,6 +41,85 @@ void main() {
|
|||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('registerCommands exposes infra MCP tools for each CLI path', () {
|
||||
final registry = CommandRegistry();
|
||||
registerCommands(registry);
|
||||
|
||||
expect(
|
||||
registry.mcpTools.map((tool) => tool.name),
|
||||
containsAll([
|
||||
'infra_list_services',
|
||||
'infra_show_service',
|
||||
'infra_validate_services',
|
||||
'infra_configure_service',
|
||||
'infra_configure_schema',
|
||||
'infra_configure_show',
|
||||
'infra_configure_apply',
|
||||
'infra_init_service',
|
||||
'infra_init_schema',
|
||||
'infra_init_run',
|
||||
'infra_install_service',
|
||||
'infra_uninstall_service',
|
||||
'infra_up_service',
|
||||
'infra_down_service',
|
||||
'infra_restart_service',
|
||||
'infra_status_service',
|
||||
'infra_logs',
|
||||
'infra_delete_service',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('infra MCP tools discover services and apply schema values', () async {
|
||||
final fs = MemoryFileSystem.test();
|
||||
_writeProjectConfig(fs);
|
||||
_writeService(fs);
|
||||
final registry = CommandRegistry();
|
||||
registerCommands(registry, fs: fs);
|
||||
final tools = {for (final tool in registry.mcpTools) tool.name: tool};
|
||||
|
||||
final services =
|
||||
jsonDecode(
|
||||
await tools['infra_list_services']!.handler({
|
||||
'project': '/project',
|
||||
}),
|
||||
)
|
||||
as List<dynamic>;
|
||||
expect(services.single['id'], 'postgres');
|
||||
|
||||
final schema =
|
||||
jsonDecode(
|
||||
await tools['infra_configure_schema']!.handler({
|
||||
'project': '/project',
|
||||
'service': 'postgres',
|
||||
}),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
expect(schema['schema'], containsPair('type', 'object'));
|
||||
|
||||
final applied =
|
||||
jsonDecode(
|
||||
await tools['infra_configure_apply']!.handler({
|
||||
'project': '/project',
|
||||
'service': 'postgres',
|
||||
'values': {'port': 5432},
|
||||
'set': ['credentials.user=dew'],
|
||||
}),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
final config = applied['config'] as Map<String, dynamic>;
|
||||
expect(config['port'], 5432);
|
||||
expect(config['credentials'], containsPair('user', 'dew'));
|
||||
expect(
|
||||
fs
|
||||
.file(
|
||||
'/project/.project/infrastructure/services/postgres/config/configure.json',
|
||||
)
|
||||
.existsSync(),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('InfraRepository', () {
|
||||
|
|
@ -235,6 +314,11 @@ schemas:
|
|||
''');
|
||||
}
|
||||
|
||||
void _writeProjectConfig(MemoryFileSystem fs) {
|
||||
fs.directory('/project/.project').createSync(recursive: true);
|
||||
fs.file('/project/.project/dew.yaml').writeAsStringSync('dew: {}\n');
|
||||
}
|
||||
|
||||
Map<String, Object?> _manifestObject() => {
|
||||
'id': 'postgres',
|
||||
'name': 'PostgreSQL',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ void main() {
|
|||
expect(registry.mcpTools, hasLength(1));
|
||||
expect(registry.mcpTools.first.name, 'stub_tool');
|
||||
});
|
||||
|
||||
test('collects extra tools from McpToolProvider commands', () {
|
||||
final registry = CommandRegistry();
|
||||
registry.register(_StubProviderCommand());
|
||||
expect(registry.mcpTools, hasLength(1));
|
||||
expect(registry.mcpTools.first.name, 'provided_tool');
|
||||
});
|
||||
});
|
||||
|
||||
group('Kanban MCP tools via CommandRegistry', () {
|
||||
|
|
@ -125,3 +132,23 @@ class _StubParentCommand extends DewCommand {
|
|||
@override
|
||||
Future<void> run() async => printUsage();
|
||||
}
|
||||
|
||||
class _StubProviderCommand extends DewCommand implements McpToolProvider {
|
||||
@override
|
||||
final String name = 'provider';
|
||||
@override
|
||||
final String description = 'Provider.';
|
||||
|
||||
@override
|
||||
List<McpTool> get tools => [
|
||||
McpTool(
|
||||
name: 'provided_tool',
|
||||
description: 'A provided tool.',
|
||||
inputSchema: const {'type': 'object'},
|
||||
handler: (_) async => 'ok',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> run() async => printUsage();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue