diff --git a/packages/cli/bin/dew.dart b/packages/cli/bin/dew.dart index 2913574..344fb50 100644 --- a/packages/cli/bin/dew.dart +++ b/packages/cli/bin/dew.dart @@ -4,16 +4,17 @@ import 'package:dew_kanban/dew_kanban.dart' as kanban; import 'package:dew_mcp/dew_mcp.dart' as mcp; Future main(List args) async { - final registry = CommandRegistry(); - kanban.registerCommands(registry); - mcp.registerCommands(registry); + final commandRegistry = CommandRegistry(); + final toolRegistry = mcp.McpToolRegistry(); - final runner = CommandRunner( - 'dew', - 'A project management tool.', - ); + toolRegistry.register(kanban.KanbanToolProvider()); - for (final command in registry.commands) { + kanban.registerCommands(commandRegistry); + mcp.registerCommands(commandRegistry, toolRegistry); + + final runner = CommandRunner('dew', 'A project management tool.'); + + for (final command in commandRegistry.commands) { runner.addCommand(command); } diff --git a/packages/core/lib/dew_core.dart b/packages/core/lib/dew_core.dart index c5bb97e..1b14285 100644 --- a/packages/core/lib/dew_core.dart +++ b/packages/core/lib/dew_core.dart @@ -2,3 +2,4 @@ library; export 'src/config.dart'; export 'src/dew_core_base.dart'; +export 'src/mcp_tool_provider.dart'; diff --git a/packages/core/lib/src/mcp_tool_provider.dart b/packages/core/lib/src/mcp_tool_provider.dart new file mode 100644 index 0000000..d912405 --- /dev/null +++ b/packages/core/lib/src/mcp_tool_provider.dart @@ -0,0 +1,27 @@ +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 06a7589..d956d14 100644 --- a/packages/kanban/lib/dew_kanban.dart +++ b/packages/kanban/lib/dew_kanban.dart @@ -1,6 +1,7 @@ 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/kanban_tool_provider.dart b/packages/kanban/lib/src/kanban_tool_provider.dart new file mode 100644 index 0000000..482ed93 --- /dev/null +++ b/packages/kanban/lib/src/kanban_tool_provider.dart @@ -0,0 +1,204 @@ +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/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index 137e7b2..f55c71e 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -139,5 +139,73 @@ 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 9a5a626..8521015 100644 --- a/packages/mcp/lib/dew_mcp.dart +++ b/packages/mcp/lib/dew_mcp.dart @@ -1,13 +1,19 @@ 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 all MCP commands into [registry]. -void registerCommands(CommandRegistry registry) { - registry.register(McpCommand()); +/// 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)); } - -// TODO: Export any libraries intended for clients of this package. diff --git a/packages/mcp/lib/src/commands/serve_command.dart b/packages/mcp/lib/src/commands/serve_command.dart new file mode 100644 index 0000000..89eeb3d --- /dev/null +++ b/packages/mcp/lib/src/commands/serve_command.dart @@ -0,0 +1,41 @@ +import 'dart:io' as io; + +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; + + ServeCommand(this._toolRegistry); + + @override + final String name = 'serve'; + + @override + final String description = + 'Start the Dew MCP server on stdio. ' + 'Connect your MCP client to this process.'; + + @override + Future run() async { + final context = await ProjectContext.find(); + final tools = _toolRegistry.allTools; + + io.stderr.writeln( + 'Dew MCP server starting — ' + '${tools.length} tool(s) registered, ' + 'project root: ${context.root}', + ); + + DewMcpServer( + stdioChannel(input: io.stdin, output: io.stdout), + tools, + ); + + // Keep the process alive until the MCP client closes the connection. + await io.stdin.drain(); + } +} diff --git a/packages/mcp/lib/src/dew_mcp_base.dart b/packages/mcp/lib/src/dew_mcp_base.dart index 3b4a66a..e04a9f0 100644 --- a/packages/mcp/lib/src/dew_mcp_base.dart +++ b/packages/mcp/lib/src/dew_mcp_base.dart @@ -1,7 +1,14 @@ 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)); + } + @override final String name = 'mcp'; diff --git a/packages/mcp/lib/src/dew_mcp_server.dart b/packages/mcp/lib/src/dew_mcp_server.dart new file mode 100644 index 0000000..72b3114 --- /dev/null +++ b/packages/mcp/lib/src/dew_mcp_server.dart @@ -0,0 +1,40 @@ +import 'package:dart_mcp/server.dart'; +import 'package:dew_core/dew_core.dart'; + +/// An MCP server that serves all tools registered in a [McpToolRegistry]. +base class DewMcpServer extends MCPServer with ToolsSupport { + DewMcpServer(super.channel, List tools) + : super.fromStreamChannel( + implementation: Implementation(name: 'dew', version: '1.0.0'), + instructions: + 'Tools for managing a Dew project (kanban tickets, etc.).', + ) { + for (final tool in tools) { + registerTool( + Tool( + name: tool.name, + description: tool.description, + // ObjectSchema is an extension type over Map; + // pass validateArguments:false to avoid runtime cast issues with + // nested Map literals. + inputSchema: ObjectSchema.fromMap( + tool.inputSchema.cast(), + ), + ), + (request) async { + final args = (request.arguments ?? const {}).cast(); + try { + final text = await tool.handler(args); + return CallToolResult(content: [Content.text(text: text)]); + } catch (e) { + return CallToolResult( + content: [Content.text(text: 'Error: $e')], + isError: true, + ); + } + }, + validateArguments: false, + ); + } + } +} diff --git a/packages/mcp/lib/src/mcp_tool_registry.dart b/packages/mcp/lib/src/mcp_tool_registry.dart new file mode 100644 index 0000000..58c1661 --- /dev/null +++ b/packages/mcp/lib/src/mcp_tool_registry.dart @@ -0,0 +1,13 @@ +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/pubspec.yaml b/packages/mcp/pubspec.yaml index 3d0fac7..6fa8cab 100644 --- a/packages/mcp/pubspec.yaml +++ b/packages/mcp/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: dew_core: path: ../core + dart_mcp: ^0.5.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/mcp/test/mcp_test.dart b/packages/mcp/test/mcp_test.dart index e12515b..d489e45 100644 --- a/packages/mcp/test/mcp_test.dart +++ b/packages/mcp/test/mcp_test.dart @@ -1,19 +1,57 @@ -import 'package:dew_mcp/dew_mcp.dart'; 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(); + final cmd = McpCommand(McpToolRegistry()); expect(cmd.name, 'mcp'); expect(cmd.description, isNotEmpty); }); + test('has serve subcommand', () { + final cmd = McpCommand(McpToolRegistry()); + expect(cmd.subcommands.keys, contains('serve')); + }); + test('registerCommands adds mcp command to registry', () { - final registry = CommandRegistry(); - registerCommands(registry); - expect(registry.commands.map((c) => c.name), contains('mcp')); + final commandRegistry = CommandRegistry(); + registerCommands(commandRegistry, McpToolRegistry()); + expect(commandRegistry.commands.map((c) => c.name), contains('mcp')); + }); + }); + + group('McpToolRegistry', () { + test('starts empty', () { + expect(McpToolRegistry().allTools, 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)); }); }); } + diff --git a/pubspec.lock b/pubspec.lock index 0d53fde..a54bbc3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_mcp: + dependency: transitive + description: + name: dart_mcp + sha256: "92a2ee1cca577ed54fa4f3d5ae0e80ce2dd61e1892e793bb4f951b30eab9c4b1" + url: "https://pub.dev" + source: hosted + version: "0.5.0" file: dependency: transitive description: @@ -201,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.11.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" + url: "https://pub.dev" + source: hosted + version: "4.1.0" lints: dependency: "direct dev" description: @@ -417,6 +433,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: