Expose infra commands as MCP tools

This commit is contained in:
Chris Hendrickson 2026-05-05 00:58:38 -04:00
parent f191a276a8
commit 69fa044e5b
6 changed files with 810 additions and 14 deletions

View 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.

View file

@ -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);
}

View file

@ -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',

View file

@ -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());

View file

@ -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',

View file

@ -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();
}