Unified command-tool registration via DewToolCommand mixin
- Add DewToolCommand mixin that auto-derives MCP tool JSON Schema from ArgParser, eliminating the need to define CLI commands and MCP tools separately - Add schemaFromArgParser() to generate JSON Schema from ArgParser options - Add CommandRegistry.mcpTools recursive collector for all DewToolCommand subcommands - Refactor all kanban subcommands to use the mixin; switch get/update/delete from positional rest args to --id / -i option for schema compatibility - Promote list to a proper CLI subcommand (was MCP-only before) - Add search, comment, and config subcommands (CLI + MCP tools) - Add TicketStore.addComment() for non-destructive comment appending - Simplify mcp.registerCommands() to take only CommandRegistry - Simplify CLI entry point (no more KanbanToolProvider/McpToolRegistry) - Delete stale files: kanban_tool_provider.dart, mcp_tool_provider.dart, mcp_tool_registry.dart (superseded by DewToolCommand mixin) - Add tools/mcp_client.dart debug client for manual MCP server testing - Update .vscode/mcp.json with correct server config All 26 tests pass, dart analyze clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
71d5fc0f75
commit
7b83572f7a
23 changed files with 544 additions and 440 deletions
|
|
@ -5,12 +5,9 @@ import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
final commandRegistry = CommandRegistry();
|
final commandRegistry = CommandRegistry();
|
||||||
final toolRegistry = mcp.McpToolRegistry();
|
|
||||||
|
|
||||||
toolRegistry.register(kanban.KanbanToolProvider());
|
|
||||||
|
|
||||||
kanban.registerCommands(commandRegistry);
|
kanban.registerCommands(commandRegistry);
|
||||||
mcp.registerCommands(commandRegistry, toolRegistry);
|
mcp.registerCommands(commandRegistry);
|
||||||
|
|
||||||
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,3 @@ library;
|
||||||
|
|
||||||
export 'src/config.dart';
|
export 'src/config.dart';
|
||||||
export 'src/dew_core_base.dart';
|
export 'src/dew_core_base.dart';
|
||||||
export 'src/mcp_tool_provider.dart';
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,134 @@
|
||||||
|
import 'package:args/args.dart';
|
||||||
import 'package:args/command_runner.dart';
|
import 'package:args/command_runner.dart';
|
||||||
|
|
||||||
|
typedef McpToolHandler = Future<String> Function(Map<String, dynamic> 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<String, dynamic> 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<McpTool> 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<String, dynamic> schemaFromArgParser(ArgParser parser) {
|
||||||
|
final properties = <String, dynamic>{};
|
||||||
|
final required = <String>[];
|
||||||
|
|
||||||
|
for (final entry in parser.options.entries) {
|
||||||
|
final name = entry.key;
|
||||||
|
final option = entry.value;
|
||||||
|
if (name == 'help') continue;
|
||||||
|
|
||||||
|
final prop = <String, dynamic>{};
|
||||||
|
|
||||||
|
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.
|
/// Base class for all Dew CLI commands.
|
||||||
///
|
///
|
||||||
/// Feature packages extend this class to provide their commands, then register
|
/// Feature packages extend this class to provide their commands, then register
|
||||||
/// them via [CommandRegistry] so the CLI can assemble them at startup.
|
/// them via [CommandRegistry] so the CLI can assemble them at startup.
|
||||||
abstract class DewCommand extends Command<void> {}
|
abstract class DewCommand extends Command<void> {}
|
||||||
|
|
||||||
|
/// 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<String, dynamic> 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<String> callAsTool(Map<String, dynamic> 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<void> run() async {
|
||||||
|
final args = <String, dynamic>{};
|
||||||
|
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.
|
/// Holds the [DewCommand]s registered by feature packages.
|
||||||
///
|
///
|
||||||
/// The CLI creates an instance, passes it to each package's
|
/// The CLI creates an instance, passes it to each package's
|
||||||
|
|
@ -19,4 +142,20 @@ class CommandRegistry {
|
||||||
|
|
||||||
/// An unmodifiable view of all registered commands.
|
/// An unmodifiable view of all registered commands.
|
||||||
List<DewCommand> get commands => List.unmodifiable(_commands);
|
List<DewCommand> get commands => List.unmodifiable(_commands);
|
||||||
|
|
||||||
|
/// Collects all [McpTool]s from commands that mix in [DewToolCommand],
|
||||||
|
/// recursively including subcommands.
|
||||||
|
List<McpTool> get mcpTools {
|
||||||
|
final tools = <McpTool>[];
|
||||||
|
void collect(Command<void> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
typedef McpToolHandler = Future<String> Function(Map<String, dynamic> 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<String, dynamic> 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<McpTool> get tools;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/dew_kanban_base.dart';
|
export 'src/dew_kanban_base.dart';
|
||||||
export 'src/kanban_tool_provider.dart';
|
|
||||||
export 'src/ticket.dart';
|
export 'src/ticket.dart';
|
||||||
export 'src/ticket_store.dart';
|
export 'src/ticket_store.dart';
|
||||||
|
|
||||||
|
|
|
||||||
35
packages/kanban/lib/src/commands/add_comment_command.dart
Normal file
35
packages/kanban/lib/src/commands/add_comment_command.dart
Normal file
|
|
@ -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<String> callAsTool(Map<String, dynamic> 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../ticket_store.dart';
|
import '../ticket_store.dart';
|
||||||
|
|
||||||
class CreateCommand extends DewCommand {
|
class CreateCommand extends DewCommand with DewToolCommand {
|
||||||
CreateCommand() {
|
CreateCommand() {
|
||||||
argParser
|
argParser
|
||||||
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
|
..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.';
|
final String description = 'Create a new kanban ticket.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> run() async {
|
final String toolName = 'kanban_create_ticket';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final context = await ProjectContext.find();
|
final context = await ProjectContext.find();
|
||||||
final config = context.config.kanban;
|
final config = context.config.kanban;
|
||||||
|
|
||||||
final title = argResults!['title'] as String;
|
final title = args['title'] as String;
|
||||||
final typeId = argResults!['type'] as String;
|
final typeId = args['type'] as String;
|
||||||
final columnArg = argResults!['column'] as String?;
|
final columnArg = args['column'] as String?;
|
||||||
final body = argResults!['body'] as String? ?? '';
|
final body = args['body'] as String? ?? '';
|
||||||
|
|
||||||
if (!config.ticketTypes.any((t) => t.id == typeId)) {
|
if (!config.ticketTypes.any((t) => t.id == typeId)) {
|
||||||
usageException(
|
throw ArgumentError(
|
||||||
'Unknown type "$typeId". '
|
'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;
|
final column = columnArg ?? config.columns.first.id;
|
||||||
if (!config.columns.any((c) => c.id == column)) {
|
if (!config.columns.any((c) => c.id == column)) {
|
||||||
usageException(
|
throw ArgumentError(
|
||||||
'Unknown column "$column". '
|
'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'),
|
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||||
prefix: config.prefix,
|
prefix: config.prefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
final ticket = await store.create(
|
final ticket = await store.create(
|
||||||
title: title,
|
title: title,
|
||||||
type: typeId,
|
type: typeId,
|
||||||
column: column,
|
column: column,
|
||||||
body: body,
|
body: body,
|
||||||
);
|
);
|
||||||
|
return 'Created ${ticket.id}: ${ticket.title}';
|
||||||
print('Created ${ticket.id}.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,16 @@ import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../ticket_store.dart';
|
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
|
@override
|
||||||
final String name = 'delete';
|
final String name = 'delete';
|
||||||
|
|
||||||
|
|
@ -11,22 +20,17 @@ class DeleteCommand extends DewCommand {
|
||||||
final String description = 'Delete a kanban ticket.';
|
final String description = 'Delete a kanban ticket.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> run() async {
|
final String toolName = 'kanban_delete_ticket';
|
||||||
final rest = argResults!.rest;
|
|
||||||
if (rest.isEmpty) usageException('Ticket ID is required.');
|
|
||||||
final id = rest.first.toUpperCase();
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
|
final id = (args['id'] as String).toUpperCase();
|
||||||
final context = await ProjectContext.find();
|
final context = await ProjectContext.find();
|
||||||
final store = TicketStore(
|
final store = TicketStore(
|
||||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||||
prefix: context.config.kanban.prefix,
|
prefix: context.config.kanban.prefix,
|
||||||
);
|
);
|
||||||
|
await store.delete(id);
|
||||||
try {
|
return 'Deleted $id.';
|
||||||
await store.delete(id);
|
|
||||||
print('Deleted $id.');
|
|
||||||
} on ArgumentError catch (e) {
|
|
||||||
usageException(e.message as String);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../ticket.dart';
|
||||||
import '../ticket_store.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
|
@override
|
||||||
final String name = 'get';
|
final String name = 'get';
|
||||||
|
|
||||||
|
|
@ -11,32 +21,34 @@ class GetCommand extends DewCommand {
|
||||||
final String description = 'Get a kanban ticket by ID.';
|
final String description = 'Get a kanban ticket by ID.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> run() async {
|
final String toolName = 'kanban_get_ticket';
|
||||||
final rest = argResults!.rest;
|
|
||||||
if (rest.isEmpty) usageException('Ticket ID is required.');
|
|
||||||
final id = rest.first.toUpperCase();
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
|
final id = (args['id'] as String).toUpperCase();
|
||||||
final context = await ProjectContext.find();
|
final context = await ProjectContext.find();
|
||||||
final store = TicketStore(
|
final store = TicketStore(
|
||||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||||
prefix: context.config.kanban.prefix,
|
prefix: context.config.kanban.prefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
final ticket = await store.findById(id);
|
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}');
|
String _format(Ticket t) {
|
||||||
print('Created: ${ticket.created.toLocal().toString().split('.').first}');
|
final buf = StringBuffer();
|
||||||
|
buf.writeln('[${t.id}] (${t.type}) [${t.column}] ${t.title}');
|
||||||
if (ticket.body.isNotEmpty) {
|
buf.writeln('Created: ${t.created.toLocal().toString().split('.').first}');
|
||||||
print('');
|
if (t.body.isNotEmpty) {
|
||||||
print(ticket.body);
|
buf.writeln();
|
||||||
|
buf.writeln(t.body);
|
||||||
}
|
}
|
||||||
|
for (final (i, comment) in t.comments.indexed) {
|
||||||
for (final (i, comment) in ticket.comments.indexed) {
|
buf.writeln();
|
||||||
print('');
|
buf.writeln('── Comment ${i + 1} ${'─' * 20}');
|
||||||
print('── Comment ${i + 1} ${'─' * 20}');
|
buf.write(comment);
|
||||||
print(comment);
|
|
||||||
}
|
}
|
||||||
|
return buf.toString().trimRight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
packages/kanban/lib/src/commands/get_config_command.dart
Normal file
23
packages/kanban/lib/src/commands/get_config_command.dart
Normal file
|
|
@ -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<String> callAsTool(Map<String, dynamic> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/kanban/lib/src/commands/list_command.dart
Normal file
46
packages/kanban/lib/src/commands/list_command.dart
Normal file
|
|
@ -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<String> callAsTool(Map<String, dynamic> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/kanban/lib/src/commands/search_command.dart
Normal file
59
packages/kanban/lib/src/commands/search_command.dart
Normal file
|
|
@ -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<String> callAsTool(Map<String, dynamic> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,50 +3,50 @@ import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../ticket_store.dart';
|
import '../ticket_store.dart';
|
||||||
|
|
||||||
class UpdateCommand extends DewCommand {
|
class UpdateCommand extends DewCommand with DewToolCommand {
|
||||||
UpdateCommand() {
|
UpdateCommand() {
|
||||||
argParser
|
argParser
|
||||||
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
||||||
..addOption('title', abbr: 't', help: 'New title.')
|
..addOption('title', abbr: 't', help: 'New title.')
|
||||||
..addOption('type', help: 'New ticket type.')
|
..addOption('type', help: 'New ticket type.')
|
||||||
..addOption('column', abbr: 'c', help: 'New column.')
|
..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
|
@override
|
||||||
final String name = 'update';
|
final String name = 'update';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String description = 'Update a kanban ticket.';
|
final String description = 'Update one or more fields on an existing kanban ticket.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> run() async {
|
final String toolName = 'kanban_update_ticket';
|
||||||
final rest = argResults!.rest;
|
|
||||||
if (rest.isEmpty) usageException('Ticket ID is required.');
|
|
||||||
final id = rest.first.toUpperCase();
|
|
||||||
|
|
||||||
final title = argResults!['title'] as String?;
|
@override
|
||||||
final typeId = argResults!['type'] as String?;
|
Future<String> callAsTool(Map<String, dynamic> args) async {
|
||||||
final column = argResults!['column'] as String?;
|
final id = (args['id'] as String).toUpperCase();
|
||||||
final body = argResults!['body'] as String?;
|
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) {
|
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 context = await ProjectContext.find();
|
||||||
final config = context.config.kanban;
|
final config = context.config.kanban;
|
||||||
|
|
||||||
if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) {
|
if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) {
|
||||||
usageException(
|
throw ArgumentError(
|
||||||
'Unknown type "$typeId". '
|
'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)) {
|
if (column != null && !config.columns.any((c) => c.id == column)) {
|
||||||
usageException(
|
throw ArgumentError(
|
||||||
'Unknown column "$column". '
|
'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'),
|
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
||||||
prefix: config.prefix,
|
prefix: config.prefix,
|
||||||
);
|
);
|
||||||
|
final ticket = await store.update(id, title: title, type: typeId, column: column, body: body);
|
||||||
try {
|
return 'Updated ${ticket.id}.';
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
|
||||||
|
import 'commands/add_comment_command.dart';
|
||||||
import 'commands/create_command.dart';
|
import 'commands/create_command.dart';
|
||||||
import 'commands/delete_command.dart';
|
import 'commands/delete_command.dart';
|
||||||
import 'commands/get_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';
|
import 'commands/update_command.dart';
|
||||||
|
|
||||||
/// Top-level CLI command for all Kanban board operations.
|
/// Top-level CLI command for all Kanban board operations.
|
||||||
class KanbanCommand extends DewCommand {
|
class KanbanCommand extends DewCommand {
|
||||||
KanbanCommand() {
|
KanbanCommand() {
|
||||||
addSubcommand(CreateCommand());
|
addSubcommand(CreateCommand());
|
||||||
|
addSubcommand(ListCommand());
|
||||||
addSubcommand(GetCommand());
|
addSubcommand(GetCommand());
|
||||||
addSubcommand(UpdateCommand());
|
addSubcommand(UpdateCommand());
|
||||||
addSubcommand(DeleteCommand());
|
addSubcommand(DeleteCommand());
|
||||||
|
addSubcommand(SearchCommand());
|
||||||
|
addSubcommand(AddCommentCommand());
|
||||||
|
addSubcommand(GetConfigCommand());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -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<McpTool> 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<TicketStore> _store() async {
|
|
||||||
final context = await ProjectContext.find();
|
|
||||||
return TicketStore(
|
|
||||||
kanbanDir: p.join(context.root, '.project', 'kanban'),
|
|
||||||
prefix: context.config.kanban.prefix,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _createTicket(Map<String, dynamic> 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<String> _listTickets(Map<String, dynamic> 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<String> _getTicket(Map<String, dynamic> 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<String> _updateTicket(Map<String, dynamic> 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<String> _deleteTicket(Map<String, dynamic> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -54,6 +54,16 @@ class TicketStore {
|
||||||
return tickets;
|
return tickets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Ticket> 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<Ticket> update(
|
Future<Ticket> update(
|
||||||
String id, {
|
String id, {
|
||||||
String? title,
|
String? title,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ void main() {
|
||||||
final cmd = KanbanCommand();
|
final cmd = KanbanCommand();
|
||||||
expect(
|
expect(
|
||||||
cmd.subcommands.keys,
|
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', () {
|
group('Ticket', () {
|
||||||
test('roundtrip serialisation', () {
|
test('roundtrip serialisation', () {
|
||||||
final t = Ticket(
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/dew_mcp_base.dart';
|
export 'src/dew_mcp_base.dart';
|
||||||
export 'src/mcp_tool_registry.dart';
|
|
||||||
|
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:dew_mcp/src/dew_mcp_base.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].
|
/// Registers the MCP command into [commandRegistry].
|
||||||
///
|
///
|
||||||
/// [toolRegistry] is passed to [McpCommand] so the `serve` subcommand can
|
/// Tools are resolved lazily from [commandRegistry.mcpTools] when the server
|
||||||
/// start the server with all registered tool providers.
|
/// starts, so all feature packages must call their own [registerCommands]
|
||||||
void registerCommands(
|
/// before this is invoked.
|
||||||
CommandRegistry commandRegistry,
|
void registerCommands(CommandRegistry commandRegistry) {
|
||||||
McpToolRegistry toolRegistry,
|
commandRegistry.register(McpCommand(commandRegistry));
|
||||||
) {
|
|
||||||
commandRegistry.register(McpCommand(toolRegistry));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ import 'package:dart_mcp/stdio.dart';
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
|
||||||
import '../dew_mcp_server.dart';
|
import '../dew_mcp_server.dart';
|
||||||
import '../mcp_tool_registry.dart';
|
|
||||||
|
|
||||||
class ServeCommand extends DewCommand {
|
class ServeCommand extends DewCommand {
|
||||||
final McpToolRegistry _toolRegistry;
|
final CommandRegistry _commandRegistry;
|
||||||
|
|
||||||
ServeCommand(this._toolRegistry);
|
ServeCommand(this._commandRegistry);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'serve';
|
final String name = 'serve';
|
||||||
|
|
@ -21,7 +20,8 @@ class ServeCommand extends DewCommand {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> run() async {
|
Future<void> 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(
|
io.stderr.writeln(
|
||||||
'Dew MCP server starting — ${tools.length} tool(s) registered.',
|
'Dew MCP server starting — ${tools.length} tool(s) registered.',
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
|
||||||
import 'commands/serve_command.dart';
|
import 'commands/serve_command.dart';
|
||||||
import 'mcp_tool_registry.dart';
|
|
||||||
|
|
||||||
/// Top-level CLI command for MCP server operations.
|
/// Top-level CLI command for MCP server operations.
|
||||||
class McpCommand extends DewCommand {
|
class McpCommand extends DewCommand {
|
||||||
McpCommand(McpToolRegistry toolRegistry) {
|
McpCommand(CommandRegistry commandRegistry) {
|
||||||
addSubcommand(ServeCommand(toolRegistry));
|
addSubcommand(ServeCommand(commandRegistry));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import 'package:dew_core/dew_core.dart';
|
|
||||||
|
|
||||||
/// Collects [McpToolProvider] instances and exposes their combined tool list.
|
|
||||||
class McpToolRegistry {
|
|
||||||
final List<McpToolProvider> _providers = [];
|
|
||||||
|
|
||||||
/// Adds [provider] to the registry.
|
|
||||||
void register(McpToolProvider provider) => _providers.add(provider);
|
|
||||||
|
|
||||||
/// All tools from every registered provider, in registration order.
|
|
||||||
List<McpTool> get allTools =>
|
|
||||||
List.unmodifiable(_providers.expand((p) => p.tools));
|
|
||||||
}
|
|
||||||
|
|
@ -2,56 +2,66 @@ import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:dew_mcp/dew_mcp.dart';
|
import 'package:dew_mcp/dew_mcp.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
class _PingProvider implements McpToolProvider {
|
|
||||||
@override
|
|
||||||
List<McpTool> get tools => [
|
|
||||||
McpTool(
|
|
||||||
name: 'ping',
|
|
||||||
description: 'Returns pong.',
|
|
||||||
inputSchema: {'type': 'object', 'properties': {}},
|
|
||||||
handler: (_) async => 'pong',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('McpCommand', () {
|
group('McpCommand', () {
|
||||||
test('has correct name and description', () {
|
test('has correct name and description', () {
|
||||||
final cmd = McpCommand(McpToolRegistry());
|
final cmd = McpCommand(CommandRegistry());
|
||||||
expect(cmd.name, 'mcp');
|
expect(cmd.name, 'mcp');
|
||||||
expect(cmd.description, isNotEmpty);
|
expect(cmd.description, isNotEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has serve subcommand', () {
|
test('has serve subcommand', () {
|
||||||
final cmd = McpCommand(McpToolRegistry());
|
final cmd = McpCommand(CommandRegistry());
|
||||||
expect(cmd.subcommands.keys, contains('serve'));
|
expect(cmd.subcommands.keys, contains('serve'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerCommands adds mcp command to registry', () {
|
test('registerCommands adds mcp command to registry', () {
|
||||||
final commandRegistry = CommandRegistry();
|
final registry = CommandRegistry();
|
||||||
registerCommands(commandRegistry, McpToolRegistry());
|
registerCommands(registry);
|
||||||
expect(commandRegistry.commands.map((c) => c.name), contains('mcp'));
|
expect(registry.commands.map((c) => c.name), contains('mcp'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('McpToolRegistry', () {
|
group('CommandRegistry.mcpTools', () {
|
||||||
test('starts empty', () {
|
test('starts empty with no feature packages registered', () {
|
||||||
expect(McpToolRegistry().allTools, isEmpty);
|
expect(CommandRegistry().mcpTools, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('register adds provider tools', () {
|
test('collects tools from DewToolCommand subcommands', () {
|
||||||
final registry = McpToolRegistry();
|
final registry = CommandRegistry();
|
||||||
registry.register(_PingProvider());
|
registry.register(_StubParentCommand());
|
||||||
expect(registry.allTools, hasLength(1));
|
expect(registry.mcpTools, hasLength(1));
|
||||||
expect(registry.allTools.first.name, 'ping');
|
expect(registry.mcpTools.first.name, 'stub_tool');
|
||||||
});
|
|
||||||
|
|
||||||
test('aggregates tools from multiple providers', () {
|
|
||||||
final registry = McpToolRegistry();
|
|
||||||
registry.register(_PingProvider());
|
|
||||||
registry.register(_PingProvider());
|
|
||||||
expect(registry.allTools, hasLength(2));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<String> callAsTool(Map<String, dynamic> args) async => 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StubParentCommand extends DewCommand {
|
||||||
|
_StubParentCommand() {
|
||||||
|
addSubcommand(_StubToolCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String name = 'parent';
|
||||||
|
@override
|
||||||
|
final String description = 'Parent.';
|
||||||
|
@override
|
||||||
|
Future<void> run() async => printUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ void main(List<String> args) async {
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('Server: ${initResult.serverInfo?.name} ${initResult.serverInfo?.version}');
|
print('Server: ${initResult.serverInfo.name} ${initResult.serverInfo.version}');
|
||||||
print('Protocol: ${initResult.protocolVersion}');
|
print('Protocol: ${initResult.protocolVersion}');
|
||||||
print('');
|
print('');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue