diff --git a/.project/kanban/done/DEW-0037.md b/.project/kanban/done/DEW-0037.md new file mode 100644 index 0000000..b5925c8 --- /dev/null +++ b/.project/kanban/done/DEW-0037.md @@ -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. diff --git a/packages/core/lib/src/dew_core_base.dart b/packages/core/lib/src/dew_core_base.dart index 459b826..e5c59b3 100644 --- a/packages/core/lib/src/dew_core_base.dart +++ b/packages/core/lib/src/dew_core_base.dart @@ -158,6 +158,9 @@ class CommandRegistry { final tools = []; void collect(Command 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); } diff --git a/packages/core/lib/src/init.dart b/packages/core/lib/src/init.dart index d360254..c6a1fb4 100644 --- a/packages/core/lib/src/init.dart +++ b/packages/core/lib/src/init.dart @@ -70,8 +70,12 @@ class InitCommand extends Command { final List _hooks; final FileSystem _fs; - InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()}) - : _fs = fs { + InitCommand( + List hooks, { + FileSystem fs = const LocalFileSystem(), + }) : this._(hooks, fs); + + InitCommand._(this._hooks, this._fs) { argParser ..addOption( 'path', diff --git a/packages/infra/lib/src/dew_infra_base.dart b/packages/infra/lib/src/dew_infra_base.dart index 39cbf94..9eb4916 100644 --- a/packages/infra/lib/src/dew_infra_base.dart +++ b/packages/infra/lib/src/dew_infra_base.dart @@ -133,6 +133,33 @@ abstract class _InfraSubcommand extends DewCommand { ); } + Future<_InfraEnvironment> _toolEnvironment(Map 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 _infraToolSchema({ + Map properties = const {}, + List required = const [], + bool includeDryRun = false, + bool includeYes = false, +}) { + final schemaProperties = { + '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 args, String key) { + final value = args[key]; + if (value == null) return null; + final text = '$value'; + return text.isEmpty ? null : text; +} + +String _requiredStringArg(Map args, String key) { + final value = _stringArg(args, key); + if (value == null) throw ArgumentError('Missing "$key".'); + return value; +} + +bool? _boolArg(Map 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 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 _stringListArg(Map 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 _objectArg(Map args, String key) { + final value = args[key]; + if (value == null) return {}; + 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 get toolInputSchema => _infraToolSchema(); + + @override + Future callAsTool(Map args) async { + final env = await _toolEnvironment(args); + final manifests = await env.repository.list(); + return _encodeToolResult( + manifests.map((manifest) => manifest.toJson()).toList(), + ); + } + @override Future 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 get toolInputSchema => _infraToolSchema( + properties: { + 'service': { + 'type': 'string', + 'description': 'Infrastructure service id.', + }, + }, + required: ['service'], + ); + + @override + Future callAsTool(Map 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 run() async { final service = _requiredServiceArg('Usage: dew infra show .'); @@ -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 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 callAsTool(Map 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 = []; + 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 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 get toolInputSchema => _infraToolSchema( + properties: { + 'service': { + 'type': 'string', + 'description': 'Infrastructure service id.', + }, + }, + required: ['service'], + ); + + @override + List 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 callAsTool(Map 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 _schemaAsTool(Map 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 _showAsTool(Map 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 _applyAsTool(Map 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 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 get toolInputSchema => _infraToolSchema( + properties: { + 'service': { + 'type': 'string', + 'description': 'Infrastructure service id.', + }, + }, + required: ['service'], + ); + + @override + List 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 callAsTool(Map 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 _schemaAsTool(Map 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 _runAsTool(Map 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 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 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 callAsTool(Map args) async { + final env = await _toolEnvironment(args); + final manifests = await _targetServicesFromTool(env, args); + final results = >[]; + 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 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 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 callAsTool(Map 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 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 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 callAsTool(Map 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 = >[]; + 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 run() async { final env = await _environment(); @@ -545,8 +1126,8 @@ Future> _targetServices( _InfraEnvironment env, { required bool allowAll, }) async { - final all = (env.options.command?['all'] as bool?) ?? false; - final rest = env.options.command?.rest ?? const []; + final all = (env.options?.command?['all'] as bool?) ?? false; + final rest = env.options?.command?.rest ?? const []; if (all) return env.repository.list(); if (rest.isEmpty) { throw UsageException('Missing service. Use a service id or --all.', ''); @@ -554,6 +1135,19 @@ Future> _targetServices( return [await env.repository.get(rest.first)]; } +Future> _targetServicesFromTool( + _InfraEnvironment env, + Map 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 _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 _applyPayload( required String outputPath, }) async { final payload = {}; - 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 _applyPayload( } } +Future> _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> _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': {}, + }; + } + 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> _applyPayloadForTool( + _InfraEnvironment env, { + required InfraServiceManifest manifest, + required Map args, + required String? schemaPath, + required String outputPath, +}) async { + final payload = {}; + 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 _readJsonObject(_InfraEnvironment env, String path) { final file = env.repository.fs.file(path); final decoded = jsonDecode(file.readAsStringSync()); diff --git a/packages/infra/test/dew_infra_test.dart b/packages/infra/test/dew_infra_test.dart index defa4c8..55301bd 100644 --- a/packages/infra/test/dew_infra_test.dart +++ b/packages/infra/test/dew_infra_test.dart @@ -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; + expect(services.single['id'], 'postgres'); + + final schema = + jsonDecode( + await tools['infra_configure_schema']!.handler({ + 'project': '/project', + 'service': 'postgres', + }), + ) + as Map; + 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; + final config = applied['config'] as Map; + 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 _manifestObject() => { 'id': 'postgres', 'name': 'PostgreSQL', diff --git a/packages/mcp/test/mcp_test.dart b/packages/mcp/test/mcp_test.dart index b54f509..9bb5c3b 100644 --- a/packages/mcp/test/mcp_test.dart +++ b/packages/mcp/test/mcp_test.dart @@ -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 run() async => printUsage(); } + +class _StubProviderCommand extends DewCommand implements McpToolProvider { + @override + final String name = 'provider'; + @override + final String description = 'Provider.'; + + @override + List get tools => [ + McpTool( + name: 'provided_tool', + description: 'A provided tool.', + inputSchema: const {'type': 'object'}, + handler: (_) async => 'ok', + ), + ]; + + @override + Future run() async => printUsage(); +}