Implement MCP tool registry and kanban tool provider

- Add McpTool + McpToolProvider interface to core
- Add McpToolRegistry to mcp package (aggregates providers)
- Add DewMcpServer (MCPServer + ToolsSupport via dart_mcp 0.5.0)
- Add 'mcp serve' subcommand — starts a real stdio MCP server
- Implement KanbanToolProvider with 5 tools:
  kanban_create_ticket, kanban_list_tickets, kanban_get_ticket,
  kanban_update_ticket, kanban_delete_ticket
- Wire McpToolRegistry + KanbanToolProvider in CLI
- 26 tests passing, dart analyze clean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 15:29:46 -04:00
parent a1c36f7136
commit a74bd94547
14 changed files with 490 additions and 18 deletions

View file

@ -4,16 +4,17 @@ import 'package:dew_kanban/dew_kanban.dart' as kanban;
import 'package:dew_mcp/dew_mcp.dart' as mcp; import 'package:dew_mcp/dew_mcp.dart' as mcp;
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
final registry = CommandRegistry(); final commandRegistry = CommandRegistry();
kanban.registerCommands(registry); final toolRegistry = mcp.McpToolRegistry();
mcp.registerCommands(registry);
final runner = CommandRunner<void>( toolRegistry.register(kanban.KanbanToolProvider());
'dew',
'A project management tool.',
);
for (final command in registry.commands) { kanban.registerCommands(commandRegistry);
mcp.registerCommands(commandRegistry, toolRegistry);
final runner = CommandRunner<void>('dew', 'A project management tool.');
for (final command in commandRegistry.commands) {
runner.addCommand(command); runner.addCommand(command);
} }

View file

@ -2,3 +2,4 @@ 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';

View file

@ -0,0 +1,27 @@
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;
}

View file

@ -1,6 +1,7 @@
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';

View file

@ -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<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();
}
}

View file

@ -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);
}
});
});
} }

View file

@ -1,13 +1,19 @@
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 all MCP commands into [registry]. /// Registers the MCP command into [commandRegistry].
void registerCommands(CommandRegistry registry) { ///
registry.register(McpCommand()); /// [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.

View file

@ -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<void> 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<void>();
}
}

View file

@ -1,7 +1,14 @@
import 'package:dew_core/dew_core.dart'; 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. /// Top-level CLI command for MCP server operations.
class McpCommand extends DewCommand { class McpCommand extends DewCommand {
McpCommand(McpToolRegistry toolRegistry) {
addSubcommand(ServeCommand(toolRegistry));
}
@override @override
final String name = 'mcp'; final String name = 'mcp';

View file

@ -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<McpTool> 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<String,Object?>;
// pass validateArguments:false to avoid runtime cast issues with
// nested Map<String,dynamic> literals.
inputSchema: ObjectSchema.fromMap(
tool.inputSchema.cast<String, Object?>(),
),
),
(request) async {
final args = (request.arguments ?? const {}).cast<String, dynamic>();
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,
);
}
}
}

View file

@ -0,0 +1,13 @@
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));
}

View file

@ -12,6 +12,7 @@ environment:
dependencies: dependencies:
dew_core: dew_core:
path: ../core path: ../core
dart_mcp: ^0.5.0
dev_dependencies: dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0

View file

@ -1,19 +1,57 @@
import 'package:dew_mcp/dew_mcp.dart';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.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(); final cmd = McpCommand(McpToolRegistry());
expect(cmd.name, 'mcp'); expect(cmd.name, 'mcp');
expect(cmd.description, isNotEmpty); 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', () { test('registerCommands adds mcp command to registry', () {
final registry = CommandRegistry(); final commandRegistry = CommandRegistry();
registerCommands(registry); registerCommands(commandRegistry, McpToolRegistry());
expect(registry.commands.map((c) => c.name), contains('mcp')); 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));
}); });
}); });
} }

View file

@ -129,6 +129,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" 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: file:
dependency: transitive dependency: transitive
description: description:
@ -201,6 +209,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.11.0" 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: lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -417,6 +433,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" 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: string_scanner:
dependency: transitive dependency: transitive
description: description: