Skip to content

Commit 1872307

Browse files
authored
KAN-193/bring-mcp-to-full-feature-parity-with-cli-tui-via-kanbanoperations-trait (#165)
* feat: add kanban-domain, kanban-core, uuid, chrono, tempfile deps to kanban-mcp * feat: replace async CliExecutor with sync SyncExecutor Use std::process::Command instead of tokio::process::Command so KanbanOperations can be implemented directly. Errors now return KanbanError/KanbanResult instead of McpError. Adds execute_raw_stdout for export and built-in retry count constant. * feat: add McpContext implementing KanbanOperations trait McpContext wraps SyncExecutor and implements all 37 KanbanOperations methods, delegating each to the kanban CLI. Includes ArgsBuilder helper, ListResponse/DeletedResponse parsing types, and special handling for export (raw stdout) and import (tempfile). * feat: remove McpTools trait, replaced by KanbanOperations from kanban-domain * feat: rewrite MCP server with 37 tools via KanbanOperations trait Replace McpTools-based architecture with direct KanbanOperations implementation for compile-time feature parity. KanbanMcpServer now holds Arc<Mutex<McpContext>> with spawn_blocking macros bridging sync KanbanOperations to async MCP handlers. 23 new tools: update_board, get/update/reorder_column, restore_card, list_archived_cards, assign/unassign_card_to_sprint, get_card_branch_name, get_card_git_checkout, bulk_archive/move/assign_sprint, full sprint CRUD (create/list/get/update/activate/complete/cancel/delete), export/import. * feat: add --clear-wip-limit flag to CLI column update The ColumnUpdate domain struct supports FieldUpdate::Clear for wip_limit but the CLI had no way to trigger it. Add --clear-wip-limit flag to ColumnUpdateArgs and handle it in the column update handler. * feat: add sprint update fields to CLI (name, dates, clear flags) SprintUpdate domain struct has name_index, start_date, end_date fields that were not exposed via CLI. Add --name, --start-date, --end-date, --clear-start-date, --clear-end-date to SprintUpdateArgs. The handler resolves --name to a name_index via board.add_sprint_name_at_used_index and parses dates from YYYY-MM-DD or RFC 3339 format. * fix: error handling in MCP executor Map CLI "not found" errors to KanbanError::NotFound instead of Internal so callers can distinguish missing entities from real errors. Also fall back to parsing stderr (first line) when stdout is empty, since the CLI writes error JSON to stderr on failure. * feat: bring MCP context to full parity with CLI - Remove fragile string matching in execute_get, rely on NotFound error - Add SprintUpdateFullParams and CreateCardFullParams param structs - Add update_sprint_full passing all fields through to CLI subprocess - Add create_card_full for atomic single-call card creation - Add with_kanban_path builder for configurable binary path - Handle FieldUpdate::Clear for wip_limit in update_column - Pass start_date/end_date through in update_sprint trait impl * feat: update MCP server tools for full CLI parity - Fix mutex poisoning: replace .unwrap() with error mapping in spawn_op!/spawn_op_ref! macros - Make context/executor modules pub for integration tests - Replace two-step create+update in tool_create_card with atomic create_card_full single CLI call - Extend UpdateSprintRequest with name, start/end dates, clear flags - Extend UpdateColumnRequest with clear_wip_limit - Update tool handlers to use new param structs * test: add unit tests for MCP helpers and ArgsBuilder 32 tests covering: - parse_uuid: valid, invalid, empty - parse_priority: all variants, case insensitivity, invalid - parse_status: all variants, hyphen/underscore normalization, invalid - parse_datetime: RFC 3339, date-only, invalid - parse_uuids_csv: single, multiple, spaces, invalid in list - to_call_tool_result/to_call_tool_result_json: serialization - ArgsBuilder: new, add_opt, add_opt_num, add_flag, add_field_str, chaining * test: add integration tests for MCP round-trips 13 tests exercising McpContext through the real kanban CLI binary: - Board CRUD + get nonexistent returns None - Column create, list, update, reorder - Card create, get, move, archive, restore - Card create_full with all optional fields - Sprint create, list, activate, complete, cancel - Sprint update_full with name and dates - Card-sprint assign/unassign - Bulk archive, bulk move - Export/import round-trip Uses locally-built binary via with_kanban_path to test against current code rather than the installed system binary. * chore: cargo fmt * chore: add changeset * fix: remove MCP trait parity bypasses and broken clear flags Remove create_card_full and update_sprint_full bypass methods that circumvented the KanbanOperations trait. tool_create_card now uses the trait's two-step create+update pattern. tool_update_sprint constructs a SprintUpdate and routes through the trait. Add name: Option<String> to SprintUpdate so MCP can pass --name without computing name_index. Remove broken clear_description and clear_points flags from UpdateCardRequest — clear_description was silently dropped and clear_points generated an invalid CLI arg. * refactor: remove 4 dead pre-animation functions from TUI card_handlers Remove delete_card, restore_selected_cards, permanent_delete_selected_cards, and permanent_delete_card_at. These were replaced by animation-based equivalents and all had #[allow(dead_code)]. * chore: update changeset * fix: build kanban from source in Nix and support KANBAN_BIN in tests Build default.nix from local source tree instead of a pinned GitHub commit so kanban and kanban-mcp stay in sync during development. Add KANBAN_BIN env var support to integration tests and provide it via nativeCheckInputs in the kanban-mcp Nix build so tests can find the CLI binary in the Nix sandbox. * docs: add MCP server to README quick start * fix: add Display impls for CardPriority/CardStatus, replace fragile Debug formatting * fix: use parking_lot::Mutex to prevent poisoning, map errors to proper MCP types * fix: add 30s timeout to CLI subprocess, fix multiline stderr parsing * refactor: move sprint name→index conversion into UpdateSprint command * feat: extend create_card with optional fields, remove two-phase creation Add CreateCardOptions struct to pass description, priority, points, and due_date at card creation time. This eliminates the two-phase create+update pattern previously used by CLI and MCP handlers. * chore: cargo fmt
1 parent 5daffa5 commit 1872307

25 files changed

+2283
-656
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
bump: patch
3+
---
4+
5+
- test: add integration tests for MCP round-trips
6+
- test: add unit tests for MCP helpers and ArgsBuilder
7+
- feat: update MCP server tools for full CLI parity
8+
- feat: bring MCP context to full parity with CLI
9+
- fix: error handling in MCP executor
10+
- feat: add sprint update fields to CLI (name, dates, clear flags)
11+
- feat: add --clear-wip-limit flag to CLI column update
12+
- feat: rewrite MCP server with 37 tools via KanbanOperations trait
13+
- feat: remove McpTools trait, replaced by KanbanOperations from kanban-domain
14+
- feat: add McpContext implementing KanbanOperations trait
15+
- feat: replace async CliExecutor with sync SyncExecutor
16+
- feat: add kanban-domain, kanban-core, uuid, chrono, tempfile deps to kanban-mcp
17+
- fix: remove create_card_full bypass, use trait two-step create+update pattern
18+
- fix: remove update_sprint_full bypass, route through trait's update_sprint
19+
- feat: add name field to SprintUpdate for MCP name passthrough
20+
- fix: remove broken clear_description and clear_points MCP flags
21+
- refactor: remove 4 dead pre-animation functions from TUI card_handlers

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ kanban myboard.json # Load a board from file
6969
3. Add cards with `n` and organize them
7070
4. Press `x` to export as JSON
7171

72+
### MCP Server
73+
74+
```bash
75+
nix run github:fulsomenko/kanban#kanban-mcp
76+
```
77+
78+
Provides full read/write access to your boards, cards, columns, and sprints over the [Model Context Protocol](https://modelcontextprotocol.io) for use with LLM tools like Claude Code, Cursor, etc.
79+
7280
### CLI
7381

7482
```bash

crates/kanban-cli/src/cli.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ pub struct ColumnUpdateArgs {
141141
pub position: Option<i32>,
142142
#[arg(long)]
143143
pub wip_limit: Option<u32>,
144+
#[arg(long)]
145+
pub clear_wip_limit: bool,
144146
}
145147

146148
// Card commands
@@ -343,9 +345,19 @@ pub struct SprintUpdateArgs {
343345
/// Sprint ID to update
344346
pub id: Uuid,
345347
#[arg(long)]
348+
pub name: Option<String>,
349+
#[arg(long)]
346350
pub prefix: Option<String>,
347351
#[arg(long)]
348352
pub card_prefix: Option<String>,
353+
#[arg(long)]
354+
pub start_date: Option<String>,
355+
#[arg(long)]
356+
pub end_date: Option<String>,
357+
#[arg(long)]
358+
pub clear_start_date: bool,
359+
#[arg(long)]
360+
pub clear_end_date: bool,
349361
}
350362

351363
// Export/Import commands

crates/kanban-cli/src/context.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ impl KanbanOperations for CliContext {
276276
board_id: Uuid,
277277
column_id: Uuid,
278278
title: String,
279+
options: kanban_domain::CreateCardOptions,
279280
) -> KanbanResult<Card> {
280281
use kanban_domain::commands::CreateCard;
281282
let position = self
@@ -288,6 +289,7 @@ impl KanbanOperations for CliContext {
288289
column_id,
289290
title,
290291
position,
292+
options,
291293
};
292294
self.execute(Box::new(cmd))?;
293295
self.cards.last().cloned().ok_or_else(|| {

crates/kanban-cli/src/handlers/card.rs

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,15 @@ use crate::cli::{CardAction, CardCreateArgs, CardListArgs, CardUpdateArgs};
22
use crate::context::CliContext;
33
use crate::output;
44
use kanban_domain::{
5-
CardListFilter, CardPriority, CardStatus, CardSummary, CardUpdate, FieldUpdate,
6-
KanbanOperations,
5+
CardListFilter, CardPriority, CardStatus, CardSummary, CardUpdate, CreateCardOptions,
6+
FieldUpdate, KanbanOperations,
77
};
88

99
pub async fn handle(ctx: &mut CliContext, action: CardAction) -> anyhow::Result<()> {
1010
match action {
1111
CardAction::Create(args) => {
12-
let title = args.title.clone();
13-
let mut card = ctx.create_card(args.board_id, args.column_id, title)?;
14-
15-
if args.description.is_some()
16-
|| args.priority.is_some()
17-
|| args.points.is_some()
18-
|| args.due_date.is_some()
19-
{
20-
let updates =
21-
build_card_update_from_create(&args).map_err(|e| anyhow::anyhow!(e))?;
22-
card = ctx.update_card(card.id, updates)?;
23-
}
24-
12+
let options = build_create_options(&args).map_err(|e| anyhow::anyhow!(e))?;
13+
let card = ctx.create_card(args.board_id, args.column_id, args.title, options)?;
2514
ctx.save().await?;
2615
output::output_success(&card);
2716
}
@@ -135,33 +124,20 @@ fn build_filter(args: &CardListArgs) -> Result<CardListFilter, String> {
135124
})
136125
}
137126

138-
fn build_card_update_from_create(args: &CardCreateArgs) -> Result<CardUpdate, String> {
127+
fn build_create_options(args: &CardCreateArgs) -> Result<CreateCardOptions, String> {
139128
let priority = match &args.priority {
140129
Some(p) => Some(parse_priority(p)?),
141130
None => None,
142131
};
143-
Ok(CardUpdate {
144-
title: None,
145-
description: args
146-
.description
147-
.clone()
148-
.map(FieldUpdate::Set)
149-
.unwrap_or(FieldUpdate::NoChange),
132+
let due_date = match &args.due_date {
133+
Some(d) => Some(parse_datetime(d)?),
134+
None => None,
135+
};
136+
Ok(CreateCardOptions {
137+
description: args.description.clone(),
150138
priority,
151-
status: None,
152-
position: None,
153-
column_id: None,
154-
points: args
155-
.points
156-
.map(FieldUpdate::Set)
157-
.unwrap_or(FieldUpdate::NoChange),
158-
due_date: match &args.due_date {
159-
Some(d) => FieldUpdate::Set(parse_datetime(d)?),
160-
None => FieldUpdate::NoChange,
161-
},
162-
sprint_id: FieldUpdate::NoChange,
163-
assigned_prefix: FieldUpdate::NoChange,
164-
card_prefix: FieldUpdate::NoChange,
139+
points: args.points,
140+
due_date,
165141
})
166142
}
167143

crates/kanban-cli/src/handlers/column.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ async fn handle_update(
4747
let updates = ColumnUpdate {
4848
name: args.name,
4949
position: args.position,
50-
wip_limit: args
51-
.wip_limit
52-
.map(|w| w as i32)
53-
.map(FieldUpdate::Set)
54-
.unwrap_or(FieldUpdate::NoChange),
50+
wip_limit: if args.clear_wip_limit {
51+
FieldUpdate::Clear
52+
} else {
53+
args.wip_limit
54+
.map(|w| w as i32)
55+
.map(FieldUpdate::Set)
56+
.unwrap_or(FieldUpdate::NoChange)
57+
},
5558
};
5659
let column = ctx.update_column(args.id, updates)?;
5760
ctx.save().await?;

crates/kanban-cli/src/handlers/sprint.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@ use crate::context::CliContext;
33
use crate::output;
44
use kanban_domain::{FieldUpdate, KanbanOperations, SprintUpdate};
55

6+
fn parse_datetime(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
7+
chrono::DateTime::parse_from_rfc3339(s)
8+
.map(|dt| dt.with_timezone(&chrono::Utc))
9+
.or_else(|_| {
10+
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
11+
.map_err(|_| ())
12+
.and_then(|d| d.and_hms_opt(0, 0, 0).ok_or(()))
13+
.map(|dt| dt.and_utc())
14+
})
15+
.map_err(|_| {
16+
format!(
17+
"Invalid date '{}'. Supported formats: YYYY-MM-DD or RFC 3339 (e.g., 2024-01-15T10:30:00Z)",
18+
s
19+
)
20+
})
21+
}
22+
623
pub async fn handle(ctx: &mut CliContext, action: SprintAction) -> anyhow::Result<()> {
724
match action {
825
SprintAction::Create {
@@ -54,7 +71,26 @@ async fn handle_update(
5471
ctx: &mut CliContext,
5572
args: SprintUpdateArgs,
5673
) -> anyhow::Result<kanban_domain::Sprint> {
74+
let start_date = if args.clear_start_date {
75+
FieldUpdate::Clear
76+
} else {
77+
match args.start_date {
78+
Some(d) => FieldUpdate::Set(parse_datetime(&d).map_err(anyhow::Error::msg)?),
79+
None => FieldUpdate::NoChange,
80+
}
81+
};
82+
83+
let end_date = if args.clear_end_date {
84+
FieldUpdate::Clear
85+
} else {
86+
match args.end_date {
87+
Some(d) => FieldUpdate::Set(parse_datetime(&d).map_err(anyhow::Error::msg)?),
88+
None => FieldUpdate::NoChange,
89+
}
90+
};
91+
5792
let updates = SprintUpdate {
93+
name: args.name,
5894
name_index: FieldUpdate::NoChange,
5995
prefix: args
6096
.prefix
@@ -65,8 +101,8 @@ async fn handle_update(
65101
.map(FieldUpdate::Set)
66102
.unwrap_or(FieldUpdate::NoChange),
67103
status: None,
68-
start_date: FieldUpdate::NoChange,
69-
end_date: FieldUpdate::NoChange,
104+
start_date,
105+
end_date,
70106
};
71107
let sprint = ctx.update_sprint(args.id, updates)?;
72108
ctx.save().await?;

crates/kanban-domain/src/card.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use chrono::{DateTime, Utc};
22
use serde::{Deserialize, Serialize};
3+
use std::fmt;
34
use uuid::Uuid;
45

56
use crate::{board::Board, column::ColumnId, field_update::FieldUpdate, sprint::Sprint, SprintLog};
@@ -23,6 +24,28 @@ pub enum CardStatus {
2324
Done,
2425
}
2526

27+
impl fmt::Display for CardPriority {
28+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
29+
match self {
30+
Self::Low => write!(f, "low"),
31+
Self::Medium => write!(f, "medium"),
32+
Self::High => write!(f, "high"),
33+
Self::Critical => write!(f, "critical"),
34+
}
35+
}
36+
}
37+
38+
impl fmt::Display for CardStatus {
39+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40+
match self {
41+
Self::Todo => write!(f, "todo"),
42+
Self::InProgress => write!(f, "in_progress"),
43+
Self::Blocked => write!(f, "blocked"),
44+
Self::Done => write!(f, "done"),
45+
}
46+
}
47+
}
48+
2649
/// Represents card lifecycle operation types.
2750
/// Used for visual feedback during card operations.
2851
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -331,6 +354,14 @@ pub struct CardUpdate {
331354
pub card_prefix: FieldUpdate<String>,
332355
}
333356

357+
#[derive(Debug, Clone, Default)]
358+
pub struct CreateCardOptions {
359+
pub description: Option<String>,
360+
pub priority: Option<CardPriority>,
361+
pub points: Option<u8>,
362+
pub due_date: Option<DateTime<Utc>>,
363+
}
364+
334365
impl GraphNode for Card {
335366
fn node_id(&self) -> Uuid {
336367
self.id

crates/kanban-domain/src/commands/card_commands.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::{Command, CommandContext};
22
use crate::dependencies::card_graph::CardGraphExt;
3-
use crate::CardUpdate;
3+
use crate::{CardUpdate, CreateCardOptions};
44
use chrono::Utc;
55
use kanban_core::KanbanResult;
66
use uuid::Uuid;
@@ -30,6 +30,7 @@ pub struct CreateCard {
3030
pub column_id: Uuid,
3131
pub title: String,
3232
pub position: i32,
33+
pub options: CreateCardOptions,
3334
}
3435

3536
impl Command for CreateCard {
@@ -53,6 +54,37 @@ impl Command for CreateCard {
5354
&prefix,
5455
);
5556
context.cards.push(card);
57+
58+
// Apply optional fields
59+
if self.options.description.is_some()
60+
|| self.options.priority.is_some()
61+
|| self.options.points.is_some()
62+
|| self.options.due_date.is_some()
63+
{
64+
if let Some(card) = context.cards.last_mut() {
65+
let updates = CardUpdate {
66+
description: self
67+
.options
68+
.description
69+
.clone()
70+
.map(crate::FieldUpdate::Set)
71+
.unwrap_or(crate::FieldUpdate::NoChange),
72+
priority: self.options.priority,
73+
points: self
74+
.options
75+
.points
76+
.map(crate::FieldUpdate::Set)
77+
.unwrap_or(crate::FieldUpdate::NoChange),
78+
due_date: self
79+
.options
80+
.due_date
81+
.map(crate::FieldUpdate::Set)
82+
.unwrap_or(crate::FieldUpdate::NoChange),
83+
..Default::default()
84+
};
85+
card.update(updates);
86+
}
87+
}
5688
}
5789
Ok(())
5890
}

0 commit comments

Comments
 (0)