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>[];
|
final tools = <McpTool>[];
|
||||||
void collect(Command<void> cmd) {
|
void collect(Command<void> cmd) {
|
||||||
if (cmd is DewToolCommand) tools.add(cmd.toMcpTool());
|
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) {
|
for (final sub in cmd.subcommands.values) {
|
||||||
collect(sub);
|
collect(sub);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,12 @@ class InitCommand extends Command<void> {
|
||||||
final List<DewInitHook> _hooks;
|
final List<DewInitHook> _hooks;
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()})
|
InitCommand(
|
||||||
: _fs = fs {
|
List<DewInitHook> hooks, {
|
||||||
|
FileSystem fs = const LocalFileSystem(),
|
||||||
|
}) : this._(hooks, fs);
|
||||||
|
|
||||||
|
InitCommand._(this._hooks, this._fs) {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'path',
|
'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!;
|
ArgResults _infraOptions() => (parent as InfraCommand).argResults!;
|
||||||
|
|
||||||
String _requiredServiceArg([String? usage]) {
|
String _requiredServiceArg([String? usage]) {
|
||||||
|
|
@ -168,7 +195,7 @@ class _InfraEnvironment {
|
||||||
});
|
});
|
||||||
|
|
||||||
final ProjectContext projectContext;
|
final ProjectContext projectContext;
|
||||||
final ArgResults options;
|
final ArgResults? options;
|
||||||
final InfraRepository repository;
|
final InfraRepository repository;
|
||||||
final InfraValidator validator;
|
final InfraValidator validator;
|
||||||
final ContainerRuntimeRegistry runtimeRegistry;
|
final ContainerRuntimeRegistry runtimeRegistry;
|
||||||
|
|
@ -181,7 +208,116 @@ class _InfraEnvironment {
|
||||||
runtimeRegistry.forKind(manifest.runtime);
|
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});
|
InfraListCommand({required super.fs, required super.runtimeRegistry});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -190,6 +326,21 @@ class InfraListCommand extends _InfraSubcommand {
|
||||||
@override
|
@override
|
||||||
final String description = 'List infrastructure services.';
|
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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final env = await _environment();
|
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});
|
InfraShowCommand({required super.fs, required super.runtimeRegistry});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -219,6 +370,39 @@ class InfraShowCommand extends _InfraSubcommand {
|
||||||
@override
|
@override
|
||||||
final String description = 'Show service manifest and runtime details.';
|
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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final service = _requiredServiceArg('Usage: dew infra show <service>.');
|
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}) {
|
InfraValidateCommand({required super.fs, required super.runtimeRegistry}) {
|
||||||
argParser.addFlag('all', negatable: false, help: 'Validate all services.');
|
argParser.addFlag('all', negatable: false, help: 'Validate all services.');
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +441,37 @@ class InfraValidateCommand extends _InfraSubcommand {
|
||||||
@override
|
@override
|
||||||
final String description = 'Validate service manifests and referenced files.';
|
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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final env = await _environment();
|
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}) {
|
InfraConfigureCommand({required super.fs, required super.runtimeRegistry}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption('file', help: 'JSON configuration file for apply.')
|
..addOption('file', help: 'JSON configuration file for apply.')
|
||||||
|
|
@ -304,6 +521,138 @@ class InfraConfigureCommand extends _InfraSubcommand {
|
||||||
final String description =
|
final String description =
|
||||||
'Inspect or apply service configuration schema values.';
|
'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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final rest = argResults?.rest ?? const [];
|
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}) {
|
InfraInitCommand({required super.fs, required super.runtimeRegistry}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption('file', help: 'JSON initialization file for run.')
|
..addOption('file', help: 'JSON initialization file for run.')
|
||||||
|
|
@ -350,6 +701,109 @@ class InfraInitCommand extends _InfraSubcommand {
|
||||||
@override
|
@override
|
||||||
final String description = 'Inspect or run service initialization options.';
|
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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final rest = argResults?.rest ?? const [];
|
final rest = argResults?.rest ?? const [];
|
||||||
|
|
@ -379,7 +833,7 @@ class InfraInitCommand extends _InfraSubcommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class InfraRuntimeCommand extends _InfraSubcommand {
|
class InfraRuntimeCommand extends _InfraSubcommand with DewToolCommand {
|
||||||
InfraRuntimeCommand(
|
InfraRuntimeCommand(
|
||||||
this.name, {
|
this.name, {
|
||||||
required super.fs,
|
required super.fs,
|
||||||
|
|
@ -402,6 +856,39 @@ class InfraRuntimeCommand extends _InfraSubcommand {
|
||||||
_ => 'Manage infrastructure services.',
|
_ => '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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final env = await _environment();
|
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}) {
|
InfraLogsCommand({required super.fs, required super.runtimeRegistry}) {
|
||||||
argParser
|
argParser
|
||||||
..addFlag('follow', abbr: 'f', negatable: false, help: 'Follow logs.')
|
..addFlag('follow', abbr: 'f', negatable: false, help: 'Follow logs.')
|
||||||
|
|
@ -471,6 +958,52 @@ class InfraLogsCommand extends _InfraSubcommand {
|
||||||
@override
|
@override
|
||||||
final String description = 'Show service logs.';
|
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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final env = await _environment();
|
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}) {
|
InfraDeleteCommand({required super.fs, required super.runtimeRegistry}) {
|
||||||
argParser
|
argParser
|
||||||
..addFlag(
|
..addFlag(
|
||||||
|
|
@ -515,6 +1048,54 @@ class InfraDeleteCommand extends _InfraSubcommand {
|
||||||
@override
|
@override
|
||||||
final String description = 'Delete service runtime artifacts.';
|
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
|
@override
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
final env = await _environment();
|
final env = await _environment();
|
||||||
|
|
@ -545,8 +1126,8 @@ Future<List<InfraServiceManifest>> _targetServices(
|
||||||
_InfraEnvironment env, {
|
_InfraEnvironment env, {
|
||||||
required bool allowAll,
|
required bool allowAll,
|
||||||
}) async {
|
}) async {
|
||||||
final all = (env.options.command?['all'] as bool?) ?? false;
|
final all = (env.options?.command?['all'] as bool?) ?? false;
|
||||||
final rest = env.options.command?.rest ?? const <String>[];
|
final rest = env.options?.command?.rest ?? const <String>[];
|
||||||
if (all) return env.repository.list();
|
if (all) return env.repository.list();
|
||||||
if (rest.isEmpty) {
|
if (rest.isEmpty) {
|
||||||
throw UsageException('Missing service. Use a service id or --all.', '');
|
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)];
|
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 {
|
Future<void> _printSchema(_InfraEnvironment env, String? schemaPath) async {
|
||||||
if (schemaPath == null) throw ArgumentError('No schema path is declared.');
|
if (schemaPath == null) throw ArgumentError('No schema path is declared.');
|
||||||
final file = env.repository.fs.file(schemaPath);
|
final file = env.repository.fs.file(schemaPath);
|
||||||
|
|
@ -582,7 +1176,10 @@ Future<void> _applyPayload(
|
||||||
required String outputPath,
|
required String outputPath,
|
||||||
}) async {
|
}) async {
|
||||||
final payload = <String, dynamic>{};
|
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?;
|
final filePath = command['file'] as String?;
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
payload.addAll(_readJsonObject(env, _resolveAgainstProject(env, filePath)));
|
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) {
|
Map<String, dynamic> _readJsonObject(_InfraEnvironment env, String path) {
|
||||||
final file = env.repository.fs.file(path);
|
final file = env.repository.fs.file(path);
|
||||||
final decoded = jsonDecode(file.readAsStringSync());
|
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', () {
|
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() => {
|
Map<String, Object?> _manifestObject() => {
|
||||||
'id': 'postgres',
|
'id': 'postgres',
|
||||||
'name': 'PostgreSQL',
|
'name': 'PostgreSQL',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ void main() {
|
||||||
expect(registry.mcpTools, hasLength(1));
|
expect(registry.mcpTools, hasLength(1));
|
||||||
expect(registry.mcpTools.first.name, 'stub_tool');
|
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', () {
|
group('Kanban MCP tools via CommandRegistry', () {
|
||||||
|
|
@ -125,3 +132,23 @@ class _StubParentCommand extends DewCommand {
|
||||||
@override
|
@override
|
||||||
Future<void> run() async => printUsage();
|
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