diff --git a/packages/cli/bin/dew.dart b/packages/cli/bin/dew.dart index 344fb50..2ba7e25 100644 --- a/packages/cli/bin/dew.dart +++ b/packages/cli/bin/dew.dart @@ -5,12 +5,9 @@ import 'package:dew_mcp/dew_mcp.dart' as mcp; Future main(List args) async { final commandRegistry = CommandRegistry(); - final toolRegistry = mcp.McpToolRegistry(); - - toolRegistry.register(kanban.KanbanToolProvider()); kanban.registerCommands(commandRegistry); - mcp.registerCommands(commandRegistry, toolRegistry); + mcp.registerCommands(commandRegistry); final runner = CommandRunner('dew', 'A project management tool.'); diff --git a/packages/core/lib/dew_core.dart b/packages/core/lib/dew_core.dart index 1b14285..c5bb97e 100644 --- a/packages/core/lib/dew_core.dart +++ b/packages/core/lib/dew_core.dart @@ -2,4 +2,3 @@ library; export 'src/config.dart'; export 'src/dew_core_base.dart'; -export 'src/mcp_tool_provider.dart'; diff --git a/packages/core/lib/src/dew_core_base.dart b/packages/core/lib/src/dew_core_base.dart index abf6f98..9b4c612 100644 --- a/packages/core/lib/src/dew_core_base.dart +++ b/packages/core/lib/src/dew_core_base.dart @@ -1,11 +1,134 @@ +import 'package:args/args.dart'; import 'package:args/command_runner.dart'; +typedef McpToolHandler = Future Function(Map args); + +/// A single tool exposed to an MCP client. +class McpTool { + final String name; + final String description; + + /// Raw JSON Schema object (type: object) describing the tool's parameters. + final Map inputSchema; + + final McpToolHandler handler; + + const McpTool({ + required this.name, + required this.description, + required this.inputSchema, + required this.handler, + }); +} + +/// Implement this interface to expose tools to the MCP server without needing +/// a CLI command (e.g. a background service or data source). +abstract interface class McpToolProvider { + List get tools; +} + +/// Derives a JSON Schema `object` from an [ArgParser]'s declared options. +/// +/// Each option becomes a property: +/// - flags → `boolean` +/// - multi-options → `array` of strings (with `enum` if [Option.allowed] set) +/// - options with [Option.allowed] → `string` with `enum` +/// - all others → `string` +/// +/// [Option.help] maps to `description`; [Option.mandatory] adds the name to +/// `required`. +Map schemaFromArgParser(ArgParser parser) { + final properties = {}; + final required = []; + + for (final entry in parser.options.entries) { + final name = entry.key; + final option = entry.value; + if (name == 'help') continue; + + final prop = {}; + + if (option.isFlag) { + prop['type'] = 'boolean'; + } else if (option.isMultiple) { + prop['type'] = 'array'; + prop['items'] = option.allowed != null && option.allowed!.isNotEmpty + ? {'type': 'string', 'enum': option.allowed} + : {'type': 'string'}; + } else if (option.allowed != null && option.allowed!.isNotEmpty) { + prop['type'] = 'string'; + prop['enum'] = option.allowed; + } else { + prop['type'] = 'string'; + } + + if (option.help != null && option.help!.isNotEmpty) { + prop['description'] = option.help; + } + + properties[name] = prop; + if (option.mandatory) required.add(name); + } + + return { + 'type': 'object', + 'properties': properties, + if (required.isNotEmpty) 'required': required, + }; +} + /// Base class for all Dew CLI commands. /// /// Feature packages extend this class to provide their commands, then register /// them via [CommandRegistry] so the CLI can assemble them at startup. abstract class DewCommand extends Command {} +/// Mixin that makes a [DewCommand] also act as an MCP tool. +/// +/// Commands only need to provide [toolName] and [callAsTool]. The mixin +/// derives [toolInputSchema] from [ArgParser] automatically, and provides a +/// default [run] implementation that delegates to [callAsTool]. +/// +/// Override [toolInputSchema] if the derived schema needs adjustment. +mixin DewToolCommand on DewCommand { + /// The MCP tool name (e.g. `kanban_create_ticket`). + String get toolName; + + /// JSON Schema for the tool's parameters. + /// Defaults to a schema derived from [argParser]. + Map get toolInputSchema => schemaFromArgParser(argParser); + + /// Executes the command logic given a plain [args] map. + /// + /// Returns a human-readable result string. + /// Both the default [run] and the MCP tool handler delegate here. + Future callAsTool(Map args); + + /// Builds a [McpTool] for this command. + McpTool toMcpTool() => McpTool( + name: toolName, + description: description, + inputSchema: toolInputSchema, + handler: callAsTool, + ); + + /// Default implementation: extracts named options from [argResults] and + /// delegates to [callAsTool], mapping [ArgumentError]s to [usageException]. + @override + Future run() async { + final args = {}; + for (final name in argParser.options.keys) { + if (name == 'help') continue; + args[name] = argResults![name]; + } + try { + print(await callAsTool(args)); + } on ArgumentError catch (e) { + usageException(e.message as String? ?? e.toString()); + } + } +} + /// Holds the [DewCommand]s registered by feature packages. /// /// The CLI creates an instance, passes it to each package's @@ -19,4 +142,20 @@ class CommandRegistry { /// An unmodifiable view of all registered commands. List get commands => List.unmodifiable(_commands); + + /// Collects all [McpTool]s from commands that mix in [DewToolCommand], + /// recursively including subcommands. + List get mcpTools { + final tools = []; + void collect(Command cmd) { + if (cmd is DewToolCommand) tools.add(cmd.toMcpTool()); + for (final sub in cmd.subcommands.values) { + collect(sub); + } + } + for (final cmd in _commands) { + collect(cmd); + } + return tools; + } } diff --git a/packages/core/lib/src/mcp_tool_provider.dart b/packages/core/lib/src/mcp_tool_provider.dart deleted file mode 100644 index d912405..0000000 --- a/packages/core/lib/src/mcp_tool_provider.dart +++ /dev/null @@ -1,27 +0,0 @@ -typedef McpToolHandler = Future Function(Map args); - -/// A single tool exposed to an MCP client. -class McpTool { - final String name; - final String description; - - /// Raw JSON Schema object (type: object) describing the tool's parameters. - final Map inputSchema; - - final McpToolHandler handler; - - const McpTool({ - required this.name, - required this.description, - required this.inputSchema, - required this.handler, - }); -} - -/// Implement this interface to expose tools to the MCP server. -/// -/// Feature packages implement this in their own library without needing to -/// depend on [packages/mcp] — they only depend on [packages/core]. -abstract interface class McpToolProvider { - List get tools; -} diff --git a/packages/kanban/lib/dew_kanban.dart b/packages/kanban/lib/dew_kanban.dart index d956d14..06a7589 100644 --- a/packages/kanban/lib/dew_kanban.dart +++ b/packages/kanban/lib/dew_kanban.dart @@ -1,7 +1,6 @@ library; export 'src/dew_kanban_base.dart'; -export 'src/kanban_tool_provider.dart'; export 'src/ticket.dart'; export 'src/ticket_store.dart'; diff --git a/packages/kanban/lib/src/commands/add_comment_command.dart b/packages/kanban/lib/src/commands/add_comment_command.dart new file mode 100644 index 0000000..b78bfcf --- /dev/null +++ b/packages/kanban/lib/src/commands/add_comment_command.dart @@ -0,0 +1,35 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class AddCommentCommand extends DewCommand with DewToolCommand { + AddCommentCommand() { + argParser + ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).') + ..addOption('comment', abbr: 'm', mandatory: true, help: 'Comment text to append.'); + } + + @override + final String name = 'comment'; + + @override + final String description = 'Append a comment to a kanban ticket.'; + + @override + final String toolName = 'kanban_add_comment'; + + @override + Future callAsTool(Map args) async { + final id = (args['id'] as String).toUpperCase(); + final comment = args['comment'] as String; + + final context = await ProjectContext.find(); + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: context.config.kanban.prefix, + ); + await store.addComment(id, comment); + return 'Comment added to $id.'; + } +} diff --git a/packages/kanban/lib/src/commands/create_command.dart b/packages/kanban/lib/src/commands/create_command.dart index 15bf846..74e9669 100644 --- a/packages/kanban/lib/src/commands/create_command.dart +++ b/packages/kanban/lib/src/commands/create_command.dart @@ -3,7 +3,7 @@ import 'package:path/path.dart' as p; import '../ticket_store.dart'; -class CreateCommand extends DewCommand { +class CreateCommand extends DewCommand with DewToolCommand { CreateCommand() { argParser ..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.') @@ -27,27 +27,30 @@ class CreateCommand extends DewCommand { final String description = 'Create a new kanban ticket.'; @override - Future run() async { + final String toolName = 'kanban_create_ticket'; + + @override + Future callAsTool(Map args) async { final context = await ProjectContext.find(); final config = context.config.kanban; - final title = argResults!['title'] as String; - final typeId = argResults!['type'] as String; - final columnArg = argResults!['column'] as String?; - final body = argResults!['body'] as String? ?? ''; + final title = args['title'] as String; + final typeId = args['type'] as String; + final columnArg = args['column'] as String?; + final body = args['body'] as String? ?? ''; if (!config.ticketTypes.any((t) => t.id == typeId)) { - usageException( + throw ArgumentError( 'Unknown type "$typeId". ' - 'Valid types: ${config.ticketTypes.map((t) => t.id).join(', ')}', + 'Valid: ${config.ticketTypes.map((t) => t.id).join(', ')}', ); } final column = columnArg ?? config.columns.first.id; if (!config.columns.any((c) => c.id == column)) { - usageException( + throw ArgumentError( 'Unknown column "$column". ' - 'Valid columns: ${config.columns.map((c) => c.id).join(', ')}', + 'Valid: ${config.columns.map((c) => c.id).join(', ')}', ); } @@ -55,14 +58,12 @@ class CreateCommand extends DewCommand { kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, ); - final ticket = await store.create( title: title, type: typeId, column: column, body: body, ); - - print('Created ${ticket.id}.'); + return 'Created ${ticket.id}: ${ticket.title}'; } } diff --git a/packages/kanban/lib/src/commands/delete_command.dart b/packages/kanban/lib/src/commands/delete_command.dart index c43f3c0..40d9ae9 100644 --- a/packages/kanban/lib/src/commands/delete_command.dart +++ b/packages/kanban/lib/src/commands/delete_command.dart @@ -3,7 +3,16 @@ import 'package:path/path.dart' as p; import '../ticket_store.dart'; -class DeleteCommand extends DewCommand { +class DeleteCommand extends DewCommand with DewToolCommand { + DeleteCommand() { + argParser.addOption( + 'id', + abbr: 'i', + mandatory: true, + help: 'Ticket ID (e.g. DEW-0001).', + ); + } + @override final String name = 'delete'; @@ -11,22 +20,17 @@ class DeleteCommand extends DewCommand { final String description = 'Delete a kanban ticket.'; @override - Future run() async { - final rest = argResults!.rest; - if (rest.isEmpty) usageException('Ticket ID is required.'); - final id = rest.first.toUpperCase(); + final String toolName = 'kanban_delete_ticket'; + @override + Future callAsTool(Map args) async { + final id = (args['id'] as String).toUpperCase(); final context = await ProjectContext.find(); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, ); - - try { - await store.delete(id); - print('Deleted $id.'); - } on ArgumentError catch (e) { - usageException(e.message as String); - } + await store.delete(id); + return 'Deleted $id.'; } } diff --git a/packages/kanban/lib/src/commands/get_command.dart b/packages/kanban/lib/src/commands/get_command.dart index f980113..71d7a95 100644 --- a/packages/kanban/lib/src/commands/get_command.dart +++ b/packages/kanban/lib/src/commands/get_command.dart @@ -1,9 +1,19 @@ import 'package:dew_core/dew_core.dart'; import 'package:path/path.dart' as p; +import '../ticket.dart'; import '../ticket_store.dart'; -class GetCommand extends DewCommand { +class GetCommand extends DewCommand with DewToolCommand { + GetCommand() { + argParser.addOption( + 'id', + abbr: 'i', + mandatory: true, + help: 'Ticket ID (e.g. DEW-0001).', + ); + } + @override final String name = 'get'; @@ -11,32 +21,34 @@ class GetCommand extends DewCommand { final String description = 'Get a kanban ticket by ID.'; @override - Future run() async { - final rest = argResults!.rest; - if (rest.isEmpty) usageException('Ticket ID is required.'); - final id = rest.first.toUpperCase(); + final String toolName = 'kanban_get_ticket'; + @override + Future callAsTool(Map args) async { + final id = (args['id'] as String).toUpperCase(); final context = await ProjectContext.find(); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, ); - final ticket = await store.findById(id); - if (ticket == null) usageException('Ticket $id not found.'); + if (ticket == null) throw ArgumentError('Ticket $id not found.'); + return _format(ticket); + } - print('[${ticket.id}] (${ticket.type}) [${ticket.column}] ${ticket.title}'); - print('Created: ${ticket.created.toLocal().toString().split('.').first}'); - - if (ticket.body.isNotEmpty) { - print(''); - print(ticket.body); + String _format(Ticket t) { + final buf = StringBuffer(); + buf.writeln('[${t.id}] (${t.type}) [${t.column}] ${t.title}'); + buf.writeln('Created: ${t.created.toLocal().toString().split('.').first}'); + if (t.body.isNotEmpty) { + buf.writeln(); + buf.writeln(t.body); } - - for (final (i, comment) in ticket.comments.indexed) { - print(''); - print('── Comment ${i + 1} ${'─' * 20}'); - print(comment); + for (final (i, comment) in t.comments.indexed) { + buf.writeln(); + buf.writeln('── Comment ${i + 1} ${'─' * 20}'); + buf.write(comment); } + return buf.toString().trimRight(); } } diff --git a/packages/kanban/lib/src/commands/get_config_command.dart b/packages/kanban/lib/src/commands/get_config_command.dart new file mode 100644 index 0000000..de0d2c5 --- /dev/null +++ b/packages/kanban/lib/src/commands/get_config_command.dart @@ -0,0 +1,23 @@ +import 'package:dew_core/dew_core.dart'; + +class GetConfigCommand extends DewCommand with DewToolCommand { + @override + final String name = 'config'; + + @override + final String description = 'Show the kanban configuration (columns and ticket types).'; + + @override + final String toolName = 'kanban_get_config'; + + @override + Future callAsTool(Map args) async { + final context = await ProjectContext.find(); + final config = context.config.kanban; + + final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', '); + final types = config.ticketTypes.map((t) => '${t.id} (${t.name})').join(', '); + + return 'Columns: $columns\nTypes: $types'; + } +} diff --git a/packages/kanban/lib/src/commands/list_command.dart b/packages/kanban/lib/src/commands/list_command.dart new file mode 100644 index 0000000..173de14 --- /dev/null +++ b/packages/kanban/lib/src/commands/list_command.dart @@ -0,0 +1,46 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class ListCommand extends DewCommand with DewToolCommand { + ListCommand() { + argParser + ..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.') + ..addOption('type', abbr: 't', help: 'Filter to tickets of this type.'); + } + + @override + final String name = 'list'; + + @override + final String description = 'List kanban tickets, optionally filtered by column or type.'; + + @override + final String toolName = 'kanban_list_tickets'; + + @override + Future callAsTool(Map args) async { + final columnFilter = args['column'] as String?; + final typeFilter = args['type'] as String?; + + final context = await ProjectContext.find(); + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: context.config.kanban.prefix, + ); + var tickets = await store.list(); + + if (columnFilter != null) { + tickets = tickets.where((t) => t.column == columnFilter).toList(); + } + if (typeFilter != null) { + tickets = tickets.where((t) => t.type == typeFilter).toList(); + } + + if (tickets.isEmpty) return 'No tickets found.'; + return tickets + .map((t) => '[${t.id}] (${t.type}) [${t.column}] ${t.title}') + .join('\n'); + } +} diff --git a/packages/kanban/lib/src/commands/search_command.dart b/packages/kanban/lib/src/commands/search_command.dart new file mode 100644 index 0000000..1083a89 --- /dev/null +++ b/packages/kanban/lib/src/commands/search_command.dart @@ -0,0 +1,59 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class SearchCommand extends DewCommand with DewToolCommand { + SearchCommand() { + argParser + ..addOption( + 'query', + abbr: 'q', + mandatory: true, + help: 'Search query (matches title, body, and comments).', + ) + ..addOption('column', abbr: 'c', help: 'Restrict search to this column.') + ..addOption('type', abbr: 't', help: 'Restrict search to this ticket type.'); + } + + @override + final String name = 'search'; + + @override + final String description = 'Search tickets by text across title, body, and comments.'; + + @override + final String toolName = 'kanban_search_tickets'; + + @override + Future callAsTool(Map args) async { + final query = (args['query'] as String).toLowerCase(); + final columnFilter = args['column'] as String?; + final typeFilter = args['type'] as String?; + + final context = await ProjectContext.find(); + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: context.config.kanban.prefix, + ); + var tickets = await store.list(); + + if (columnFilter != null) { + tickets = tickets.where((t) => t.column == columnFilter).toList(); + } + if (typeFilter != null) { + tickets = tickets.where((t) => t.type == typeFilter).toList(); + } + + final matches = tickets.where((t) { + return t.title.toLowerCase().contains(query) || + t.body.toLowerCase().contains(query) || + t.comments.any((c) => c.toLowerCase().contains(query)); + }).toList(); + + if (matches.isEmpty) return 'No tickets found matching "$query".'; + return matches + .map((t) => '[${t.id}] (${t.type}) [${t.column}] ${t.title}') + .join('\n'); + } +} diff --git a/packages/kanban/lib/src/commands/update_command.dart b/packages/kanban/lib/src/commands/update_command.dart index ff4f374..221a85d 100644 --- a/packages/kanban/lib/src/commands/update_command.dart +++ b/packages/kanban/lib/src/commands/update_command.dart @@ -3,50 +3,50 @@ import 'package:path/path.dart' as p; import '../ticket_store.dart'; -class UpdateCommand extends DewCommand { +class UpdateCommand extends DewCommand with DewToolCommand { UpdateCommand() { argParser + ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('title', abbr: 't', help: 'New title.') ..addOption('type', help: 'New ticket type.') ..addOption('column', abbr: 'c', help: 'New column.') - ..addOption('body', abbr: 'b', help: 'New body (replaces existing).'); + ..addOption('body', abbr: 'b', help: 'New body (replaces existing body).'); } @override final String name = 'update'; @override - final String description = 'Update a kanban ticket.'; + final String description = 'Update one or more fields on an existing kanban ticket.'; @override - Future run() async { - final rest = argResults!.rest; - if (rest.isEmpty) usageException('Ticket ID is required.'); - final id = rest.first.toUpperCase(); + final String toolName = 'kanban_update_ticket'; - final title = argResults!['title'] as String?; - final typeId = argResults!['type'] as String?; - final column = argResults!['column'] as String?; - final body = argResults!['body'] as String?; + @override + Future callAsTool(Map args) async { + final id = (args['id'] as String).toUpperCase(); + final title = args['title'] as String?; + final typeId = args['type'] as String?; + final column = args['column'] as String?; + final body = args['body'] as String?; if (title == null && typeId == null && column == null && body == null) { - usageException('At least one option must be specified.'); + throw ArgumentError('At least one of --title, --type, --column, --body must be specified.'); } final context = await ProjectContext.find(); final config = context.config.kanban; if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) { - usageException( + throw ArgumentError( 'Unknown type "$typeId". ' - 'Valid types: ${config.ticketTypes.map((t) => t.id).join(', ')}', + 'Valid: ${config.ticketTypes.map((t) => t.id).join(', ')}', ); } - if (column != null && !config.columns.any((c) => c.id == column)) { - usageException( + throw ArgumentError( 'Unknown column "$column". ' - 'Valid columns: ${config.columns.map((c) => c.id).join(', ')}', + 'Valid: ${config.columns.map((c) => c.id).join(', ')}', ); } @@ -54,18 +54,7 @@ class UpdateCommand extends DewCommand { kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, ); - - try { - final ticket = await store.update( - id, - title: title, - type: typeId, - column: column, - body: body, - ); - print('Updated ${ticket.id}.'); - } on ArgumentError catch (e) { - usageException(e.message as String); - } + final ticket = await store.update(id, title: title, type: typeId, column: column, body: body); + return 'Updated ${ticket.id}.'; } } diff --git a/packages/kanban/lib/src/dew_kanban_base.dart b/packages/kanban/lib/src/dew_kanban_base.dart index 49afa3c..4575bd0 100644 --- a/packages/kanban/lib/src/dew_kanban_base.dart +++ b/packages/kanban/lib/src/dew_kanban_base.dart @@ -1,17 +1,25 @@ import 'package:dew_core/dew_core.dart'; +import 'commands/add_comment_command.dart'; import 'commands/create_command.dart'; import 'commands/delete_command.dart'; import 'commands/get_command.dart'; +import 'commands/get_config_command.dart'; +import 'commands/list_command.dart'; +import 'commands/search_command.dart'; import 'commands/update_command.dart'; /// Top-level CLI command for all Kanban board operations. class KanbanCommand extends DewCommand { KanbanCommand() { addSubcommand(CreateCommand()); + addSubcommand(ListCommand()); addSubcommand(GetCommand()); addSubcommand(UpdateCommand()); addSubcommand(DeleteCommand()); + addSubcommand(SearchCommand()); + addSubcommand(AddCommentCommand()); + addSubcommand(GetConfigCommand()); } @override diff --git a/packages/kanban/lib/src/kanban_tool_provider.dart b/packages/kanban/lib/src/kanban_tool_provider.dart deleted file mode 100644 index 482ed93..0000000 --- a/packages/kanban/lib/src/kanban_tool_provider.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:dew_core/dew_core.dart'; -import 'package:path/path.dart' as p; - -import 'ticket.dart'; -import 'ticket_store.dart'; - -/// Exposes Kanban board operations as MCP tools. -class KanbanToolProvider implements McpToolProvider { - @override - List get tools => [ - McpTool( - name: 'kanban_create_ticket', - description: 'Create a new kanban ticket.', - inputSchema: { - 'type': 'object', - 'properties': { - 'title': {'type': 'string', 'description': 'Ticket title.'}, - 'type': { - 'type': 'string', - 'description': 'Ticket type (e.g. task, bug).', - }, - 'column': { - 'type': 'string', - 'description': - 'Initial column. Defaults to the first configured column.', - }, - 'body': {'type': 'string', 'description': 'Ticket description.'}, - }, - 'required': ['title', 'type'], - }, - handler: _createTicket, - ), - McpTool( - name: 'kanban_list_tickets', - description: 'List kanban tickets, optionally filtered by column or type.', - inputSchema: { - 'type': 'object', - 'properties': { - 'column': { - 'type': 'string', - 'description': 'Filter to tickets in this column.', - }, - 'type': { - 'type': 'string', - 'description': 'Filter to tickets of this type.', - }, - }, - }, - handler: _listTickets, - ), - McpTool( - name: 'kanban_get_ticket', - description: 'Get a kanban ticket by ID.', - inputSchema: { - 'type': 'object', - 'properties': { - 'id': { - 'type': 'string', - 'description': 'Ticket ID (e.g. DEW-0001).', - }, - }, - 'required': ['id'], - }, - handler: _getTicket, - ), - McpTool( - name: 'kanban_update_ticket', - description: 'Update one or more fields on an existing kanban ticket.', - inputSchema: { - 'type': 'object', - 'properties': { - 'id': {'type': 'string', 'description': 'Ticket ID.'}, - 'title': {'type': 'string', 'description': 'New title.'}, - 'type': {'type': 'string', 'description': 'New ticket type.'}, - 'column': {'type': 'string', 'description': 'New column.'}, - 'body': { - 'type': 'string', - 'description': 'New body (replaces existing body).', - }, - }, - 'required': ['id'], - }, - handler: _updateTicket, - ), - McpTool( - name: 'kanban_delete_ticket', - description: 'Delete a kanban ticket by ID.', - inputSchema: { - 'type': 'object', - 'properties': { - 'id': {'type': 'string', 'description': 'Ticket ID.'}, - }, - 'required': ['id'], - }, - handler: _deleteTicket, - ), - ]; - - Future _store() async { - final context = await ProjectContext.find(); - return TicketStore( - kanbanDir: p.join(context.root, '.project', 'kanban'), - prefix: context.config.kanban.prefix, - ); - } - - Future _createTicket(Map args) async { - final context = await ProjectContext.find(); - final config = context.config.kanban; - final title = args['title'] as String; - final typeId = args['type'] as String; - final column = args['column'] as String? ?? config.columns.first.id; - final body = args['body'] as String? ?? ''; - - if (!config.ticketTypes.any((t) => t.id == typeId)) { - throw ArgumentError( - 'Unknown type "$typeId". Valid: ${config.ticketTypes.map((t) => t.id).join(', ')}', - ); - } - if (!config.columns.any((c) => c.id == column)) { - throw ArgumentError( - 'Unknown column "$column". Valid: ${config.columns.map((c) => c.id).join(', ')}', - ); - } - - final store = TicketStore( - kanbanDir: p.join(context.root, '.project', 'kanban'), - prefix: config.prefix, - ); - final ticket = await store.create( - title: title, - type: typeId, - column: column, - body: body, - ); - return 'Created ${ticket.id}: ${ticket.title}'; - } - - Future _listTickets(Map args) async { - final columnFilter = args['column'] as String?; - final typeFilter = args['type'] as String?; - final store = await _store(); - var tickets = await store.list(); - - if (columnFilter != null) { - tickets = tickets.where((t) => t.column == columnFilter).toList(); - } - if (typeFilter != null) { - tickets = tickets.where((t) => t.type == typeFilter).toList(); - } - - if (tickets.isEmpty) return 'No tickets found.'; - - return tickets - .map((t) => '[${t.id}] (${t.type}) [${t.column}] ${t.title}') - .join('\n'); - } - - Future _getTicket(Map args) async { - final id = (args['id'] as String).toUpperCase(); - final store = await _store(); - final ticket = await store.findById(id); - if (ticket == null) throw ArgumentError('Ticket $id not found.'); - return _formatTicket(ticket); - } - - Future _updateTicket(Map args) async { - final id = (args['id'] as String).toUpperCase(); - final store = await _store(); - final updated = await store.update( - id, - title: args['title'] as String?, - type: args['type'] as String?, - column: args['column'] as String?, - body: args['body'] as String?, - ); - return 'Updated ${updated.id}.\n${_formatTicket(updated)}'; - } - - Future _deleteTicket(Map args) async { - final id = (args['id'] as String).toUpperCase(); - final store = await _store(); - await store.delete(id); - return 'Deleted $id.'; - } - - String _formatTicket(Ticket t) { - final buf = StringBuffer(); - buf.writeln('[${t.id}] (${t.type}) [${t.column}] ${t.title}'); - buf.writeln( - 'Created: ${t.created.toLocal().toString().split('.').first}', - ); - if (t.body.isNotEmpty) { - buf.writeln(); - buf.writeln(t.body); - } - for (final (i, comment) in t.comments.indexed) { - buf.writeln(); - buf.writeln('── Comment ${i + 1} ${'─' * 20}'); - buf.write(comment); - } - return buf.toString().trimRight(); - } -} diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart index f1affb5..5477af6 100644 --- a/packages/kanban/lib/src/ticket_store.dart +++ b/packages/kanban/lib/src/ticket_store.dart @@ -54,6 +54,16 @@ class TicketStore { return tickets; } + Future addComment(String id, String comment) async { + final ticket = await findById(id); + if (ticket == null) throw ArgumentError('Ticket $id not found.'); + final updated = ticket.copyWith( + comments: [...ticket.comments, comment], + ); + await File(_filePath(id)).writeAsString(updated.toFileContent()); + return updated; + } + Future update( String id, { String? title, diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index f55c71e..1e758af 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -17,7 +17,7 @@ void main() { final cmd = KanbanCommand(); expect( cmd.subcommands.keys, - containsAll(['create', 'get', 'update', 'delete']), + containsAll(['create', 'list', 'get', 'update', 'delete', 'search', 'comment', 'config']), ); }); @@ -28,6 +28,94 @@ void main() { }); }); + group('CommandRegistry.mcpTools via kanban', () { + test('exposes eight tools with unique names', () { + final registry = CommandRegistry(); + registerCommands(registry); + final tools = registry.mcpTools; + expect(tools, hasLength(8)); + final names = tools.map((t) => t.name).toSet(); + expect(names, { + 'kanban_create_ticket', + 'kanban_list_tickets', + 'kanban_get_ticket', + 'kanban_update_ticket', + 'kanban_delete_ticket', + 'kanban_search_tickets', + 'kanban_add_comment', + 'kanban_get_config', + }); + }); + + test('all tools have non-empty descriptions and object inputSchema', () { + final registry = CommandRegistry(); + registerCommands(registry); + for (final tool in registry.mcpTools) { + expect(tool.description, isNotEmpty, reason: '${tool.name} description'); + expect(tool.inputSchema['type'], 'object', reason: '${tool.name} schema type'); + } + }); + + test('schema derived from argParser — create tool has required fields', () { + final registry = CommandRegistry(); + registerCommands(registry); + final create = registry.mcpTools.firstWhere((t) => t.name == 'kanban_create_ticket'); + final required = create.inputSchema['required'] as List; + expect(required, containsAll(['title', 'type'])); + }); + + test('create and list tools have working handlers', () async { + final tempDir = await Directory.systemTemp.createTemp('kanban_tool_test_'); + final origDir = Directory.current; + try { + await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true); + await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString(''' +dew: + mcp: + host: localhost + port: 9090 + kanban: + prefix: T + ticket_types: + - id: task + name: Task + columns: + - id: todo + name: To Do + color: blue +'''); + Directory.current = tempDir; + final registry = CommandRegistry(); + registerCommands(registry); + final tools = {for (final t in registry.mcpTools) t.name: t}; + + final result = await tools['kanban_create_ticket']!.handler({ + 'title': 'Hello', + 'type': 'task', + }); + expect(result, contains('T-0001')); + + final listResult = await tools['kanban_list_tickets']!.handler({}); + expect(listResult, contains('T-0001')); + + final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'}); + expect(searchResult, contains('T-0001')); + + await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'}); + + final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'}); + expect(getResult, contains('Nice ticket.')); + + final configResult = await tools['kanban_get_config']!.handler({}); + expect(configResult, contains('todo')); + expect(configResult, contains('task')); + } finally { + Directory.current = origDir; + await tempDir.delete(recursive: true); + } + }); + }); + group('Ticket', () { test('roundtrip serialisation', () { final t = Ticket( @@ -140,72 +228,6 @@ void main() { }); }); - group('KanbanToolProvider', () { - test('exposes five tools with unique names', () { - final provider = KanbanToolProvider(); - expect(provider.tools, hasLength(5)); - final names = provider.tools.map((t) => t.name).toSet(); - expect(names, { - 'kanban_create_ticket', - 'kanban_list_tickets', - 'kanban_get_ticket', - 'kanban_update_ticket', - 'kanban_delete_ticket', - }); - }); - - test('all tools have non-empty descriptions and object inputSchema', () { - for (final tool in KanbanToolProvider().tools) { - expect(tool.description, isNotEmpty, reason: '${tool.name} description'); - expect(tool.inputSchema['type'], 'object', - reason: '${tool.name} schema type'); - } - }); - - test('create and list tools have handlers that work', () async { - // Use a real temp dir with a fake dew.yaml so ProjectContext.find() - // resolves — handlers call ProjectContext.find() internally. - final tempDir = await Directory.systemTemp.createTemp('kanban_tool_test_'); - final origDir = Directory.current; - try { - await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true); - await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString(''' -dew: - mcp: - host: localhost - port: 9090 - kanban: - prefix: T - ticket_types: - - id: task - name: Task - columns: - - id: todo - name: To Do - color: blue -'''); - Directory.current = tempDir; - final provider = KanbanToolProvider(); - - final createTool = provider.tools.firstWhere( - (t) => t.name == 'kanban_create_ticket', - ); - final result = await createTool.handler({ - 'title': 'Hello', - 'type': 'task', - }); - expect(result, contains('T-0001')); - - final listTool = provider.tools.firstWhere( - (t) => t.name == 'kanban_list_tickets', - ); - final listResult = await listTool.handler({}); - expect(listResult, contains('T-0001')); - } finally { - Directory.current = origDir; - await tempDir.delete(recursive: true); - } - }); - }); } + diff --git a/packages/mcp/lib/dew_mcp.dart b/packages/mcp/lib/dew_mcp.dart index 8521015..0b8de79 100644 --- a/packages/mcp/lib/dew_mcp.dart +++ b/packages/mcp/lib/dew_mcp.dart @@ -1,19 +1,15 @@ library; export 'src/dew_mcp_base.dart'; -export 'src/mcp_tool_registry.dart'; import 'package:dew_core/dew_core.dart'; import 'package:dew_mcp/src/dew_mcp_base.dart'; -import 'package:dew_mcp/src/mcp_tool_registry.dart'; /// Registers the MCP command into [commandRegistry]. /// -/// [toolRegistry] is passed to [McpCommand] so the `serve` subcommand can -/// start the server with all registered tool providers. -void registerCommands( - CommandRegistry commandRegistry, - McpToolRegistry toolRegistry, -) { - commandRegistry.register(McpCommand(toolRegistry)); +/// Tools are resolved lazily from [commandRegistry.mcpTools] when the server +/// starts, so all feature packages must call their own [registerCommands] +/// before this is invoked. +void registerCommands(CommandRegistry commandRegistry) { + commandRegistry.register(McpCommand(commandRegistry)); } diff --git a/packages/mcp/lib/src/commands/serve_command.dart b/packages/mcp/lib/src/commands/serve_command.dart index 851726f..503ac1b 100644 --- a/packages/mcp/lib/src/commands/serve_command.dart +++ b/packages/mcp/lib/src/commands/serve_command.dart @@ -4,12 +4,11 @@ import 'package:dart_mcp/stdio.dart'; import 'package:dew_core/dew_core.dart'; import '../dew_mcp_server.dart'; -import '../mcp_tool_registry.dart'; class ServeCommand extends DewCommand { - final McpToolRegistry _toolRegistry; + final CommandRegistry _commandRegistry; - ServeCommand(this._toolRegistry); + ServeCommand(this._commandRegistry); @override final String name = 'serve'; @@ -21,7 +20,8 @@ class ServeCommand extends DewCommand { @override Future run() async { - final tools = _toolRegistry.allTools; + // Resolve tools at serve time so all feature packages are already registered. + final tools = _commandRegistry.mcpTools; io.stderr.writeln( 'Dew MCP server starting — ${tools.length} tool(s) registered.', diff --git a/packages/mcp/lib/src/dew_mcp_base.dart b/packages/mcp/lib/src/dew_mcp_base.dart index e04a9f0..012e9fd 100644 --- a/packages/mcp/lib/src/dew_mcp_base.dart +++ b/packages/mcp/lib/src/dew_mcp_base.dart @@ -1,12 +1,11 @@ import 'package:dew_core/dew_core.dart'; import 'commands/serve_command.dart'; -import 'mcp_tool_registry.dart'; /// Top-level CLI command for MCP server operations. class McpCommand extends DewCommand { - McpCommand(McpToolRegistry toolRegistry) { - addSubcommand(ServeCommand(toolRegistry)); + McpCommand(CommandRegistry commandRegistry) { + addSubcommand(ServeCommand(commandRegistry)); } @override diff --git a/packages/mcp/lib/src/mcp_tool_registry.dart b/packages/mcp/lib/src/mcp_tool_registry.dart deleted file mode 100644 index 58c1661..0000000 --- a/packages/mcp/lib/src/mcp_tool_registry.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:dew_core/dew_core.dart'; - -/// Collects [McpToolProvider] instances and exposes their combined tool list. -class McpToolRegistry { - final List _providers = []; - - /// Adds [provider] to the registry. - void register(McpToolProvider provider) => _providers.add(provider); - - /// All tools from every registered provider, in registration order. - List get allTools => - List.unmodifiable(_providers.expand((p) => p.tools)); -} diff --git a/packages/mcp/test/mcp_test.dart b/packages/mcp/test/mcp_test.dart index d489e45..ae5ae49 100644 --- a/packages/mcp/test/mcp_test.dart +++ b/packages/mcp/test/mcp_test.dart @@ -2,56 +2,66 @@ import 'package:dew_core/dew_core.dart'; import 'package:dew_mcp/dew_mcp.dart'; import 'package:test/test.dart'; -class _PingProvider implements McpToolProvider { - @override - List get tools => [ - McpTool( - name: 'ping', - description: 'Returns pong.', - inputSchema: {'type': 'object', 'properties': {}}, - handler: (_) async => 'pong', - ), - ]; -} - void main() { group('McpCommand', () { test('has correct name and description', () { - final cmd = McpCommand(McpToolRegistry()); + final cmd = McpCommand(CommandRegistry()); expect(cmd.name, 'mcp'); expect(cmd.description, isNotEmpty); }); test('has serve subcommand', () { - final cmd = McpCommand(McpToolRegistry()); + final cmd = McpCommand(CommandRegistry()); expect(cmd.subcommands.keys, contains('serve')); }); test('registerCommands adds mcp command to registry', () { - final commandRegistry = CommandRegistry(); - registerCommands(commandRegistry, McpToolRegistry()); - expect(commandRegistry.commands.map((c) => c.name), contains('mcp')); + final registry = CommandRegistry(); + registerCommands(registry); + expect(registry.commands.map((c) => c.name), contains('mcp')); }); }); - group('McpToolRegistry', () { - test('starts empty', () { - expect(McpToolRegistry().allTools, isEmpty); + group('CommandRegistry.mcpTools', () { + test('starts empty with no feature packages registered', () { + expect(CommandRegistry().mcpTools, isEmpty); }); - test('register adds provider tools', () { - final registry = McpToolRegistry(); - registry.register(_PingProvider()); - expect(registry.allTools, hasLength(1)); - expect(registry.allTools.first.name, 'ping'); - }); - - test('aggregates tools from multiple providers', () { - final registry = McpToolRegistry(); - registry.register(_PingProvider()); - registry.register(_PingProvider()); - expect(registry.allTools, hasLength(2)); + test('collects tools from DewToolCommand subcommands', () { + final registry = CommandRegistry(); + registry.register(_StubParentCommand()); + expect(registry.mcpTools, hasLength(1)); + expect(registry.mcpTools.first.name, 'stub_tool'); }); }); } +class _StubToolCommand extends DewCommand with DewToolCommand { + _StubToolCommand() { + argParser.addOption('input', mandatory: true, help: 'Some input.'); + } + + @override + final String name = 'stub'; + @override + final String description = 'A stub tool command.'; + @override + final String toolName = 'stub_tool'; + + @override + Future callAsTool(Map args) async => 'ok'; +} + +class _StubParentCommand extends DewCommand { + _StubParentCommand() { + addSubcommand(_StubToolCommand()); + } + + @override + final String name = 'parent'; + @override + final String description = 'Parent.'; + @override + Future run() async => printUsage(); +} + diff --git a/tools/mcp_client.dart b/tools/mcp_client.dart index df44cf3..9230cbe 100644 --- a/tools/mcp_client.dart +++ b/tools/mcp_client.dart @@ -81,7 +81,7 @@ void main(List args) async { exit(1); } - print('Server: ${initResult.serverInfo?.name} ${initResult.serverInfo?.version}'); + print('Server: ${initResult.serverInfo.name} ${initResult.serverInfo.version}'); print('Protocol: ${initResult.protocolVersion}'); print('');