Skip to main content

kanban_mcp/
lib.rs

1mod executor;
2mod tools_trait;
3
4use async_trait::async_trait;
5use executor::CliExecutor;
6use rmcp::{
7    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
8    model::{
9        CallToolResult, Content, ErrorData as McpError, Implementation, ProtocolVersion,
10        ServerCapabilities, ServerInfo,
11    },
12    schemars, tool, tool_handler, tool_router, ServerHandler,
13};
14use serde::Deserialize;
15use std::sync::Arc;
16use tools_trait::{CreateCardParams, McpTools, UpdateCardParams};
17
18// ============================================================================
19// Request Types (kept for MCP tool schemas)
20// ============================================================================
21
22#[derive(Debug, Deserialize, schemars::JsonSchema)]
23pub struct CreateBoardRequest {
24    #[schemars(description = "Name of the board")]
25    pub name: String,
26    #[schemars(description = "Optional card prefix (e.g., 'KAN' for KAN-1, KAN-2, etc.)")]
27    pub card_prefix: Option<String>,
28}
29
30#[derive(Debug, Deserialize, schemars::JsonSchema)]
31pub struct CreateColumnRequest {
32    #[schemars(description = "ID of the board to create the column in")]
33    pub board_id: String,
34    #[schemars(description = "Name of the column")]
35    pub name: String,
36    #[schemars(description = "Position of the column (optional, appends to end if not specified)")]
37    pub position: Option<i32>,
38}
39
40#[derive(Debug, Deserialize, schemars::JsonSchema)]
41pub struct CreateCardRequest {
42    #[schemars(description = "ID of the board")]
43    pub board_id: String,
44    #[schemars(description = "ID of the column to create the card in")]
45    pub column_id: String,
46    #[schemars(description = "Title of the card")]
47    pub title: String,
48    #[schemars(description = "Description of the card (optional)")]
49    pub description: Option<String>,
50    #[schemars(description = "Priority: 'low', 'medium', 'high', or 'critical' (optional)")]
51    pub priority: Option<String>,
52    #[schemars(description = "Story points (optional, 0-255)")]
53    pub points: Option<u8>,
54    #[schemars(description = "Due date in ISO 8601 format (optional)")]
55    pub due_date: Option<String>,
56}
57
58#[derive(Debug, Deserialize, schemars::JsonSchema)]
59pub struct ListCardsRequest {
60    #[schemars(description = "Filter cards by board ID")]
61    pub board_id: Option<String>,
62    #[schemars(description = "Filter cards by column ID")]
63    pub column_id: Option<String>,
64    #[schemars(description = "Filter cards by sprint ID")]
65    pub sprint_id: Option<String>,
66}
67
68#[derive(Debug, Deserialize, schemars::JsonSchema)]
69pub struct GetCardRequest {
70    #[schemars(description = "ID of the card to retrieve")]
71    pub card_id: String,
72}
73
74#[derive(Debug, Deserialize, schemars::JsonSchema)]
75pub struct MoveCardRequest {
76    #[schemars(description = "ID of the card to move")]
77    pub card_id: String,
78    #[schemars(description = "ID of the destination column")]
79    pub column_id: String,
80    #[schemars(description = "Position in the new column (optional)")]
81    pub position: Option<i32>,
82}
83
84#[derive(Debug, Deserialize, schemars::JsonSchema)]
85pub struct UpdateCardRequest {
86    #[schemars(description = "ID of the card to update")]
87    pub card_id: String,
88    #[schemars(description = "New title (optional)")]
89    pub title: Option<String>,
90    #[schemars(description = "New description (optional, use empty string to clear)")]
91    pub description: Option<String>,
92    #[schemars(description = "Clear description (set to true to remove description)")]
93    pub clear_description: Option<bool>,
94    #[schemars(description = "Priority: 'low', 'medium', 'high', or 'critical' (optional)")]
95    pub priority: Option<String>,
96    #[schemars(description = "Status: 'todo', 'in_progress', 'blocked', or 'done' (optional)")]
97    pub status: Option<String>,
98    #[schemars(
99        description = "Due date in ISO 8601 format (optional, use clear_due_date to remove)"
100    )]
101    pub due_date: Option<String>,
102    #[schemars(description = "Clear due date (set to true to remove due date)")]
103    pub clear_due_date: Option<bool>,
104    #[schemars(description = "Story points (optional, 0-255)")]
105    pub points: Option<u8>,
106    #[schemars(description = "Clear story points (set to true to remove points)")]
107    pub clear_points: Option<bool>,
108}
109
110#[derive(Debug, Deserialize, schemars::JsonSchema)]
111pub struct ListColumnsRequest {
112    #[schemars(description = "ID of the board to list columns for")]
113    pub board_id: String,
114}
115
116#[derive(Debug, Deserialize, schemars::JsonSchema)]
117pub struct DeleteBoardRequest {
118    #[schemars(description = "ID of the board to delete")]
119    pub board_id: String,
120}
121
122#[derive(Debug, Deserialize, schemars::JsonSchema)]
123pub struct DeleteColumnRequest {
124    #[schemars(description = "ID of the column to delete")]
125    pub column_id: String,
126}
127
128#[derive(Debug, Deserialize, schemars::JsonSchema)]
129pub struct DeleteCardRequest {
130    #[schemars(description = "ID of the card to delete")]
131    pub card_id: String,
132}
133
134#[derive(Debug, Deserialize, schemars::JsonSchema)]
135pub struct ArchiveCardRequest {
136    #[schemars(description = "ID of the card to archive")]
137    pub card_id: String,
138}
139
140#[derive(Debug, Deserialize, schemars::JsonSchema)]
141pub struct GetBoardRequest {
142    #[schemars(description = "ID of the board to retrieve")]
143    pub board_id: String,
144}
145
146// ============================================================================
147// MCP Server
148// ============================================================================
149
150#[derive(Clone)]
151pub struct KanbanMcpServer {
152    executor: Arc<CliExecutor>,
153    tool_router: ToolRouter<Self>,
154}
155
156/// Helper to build CLI args with optional parameters
157struct ArgsBuilder {
158    args: Vec<String>,
159}
160
161impl ArgsBuilder {
162    fn new(base: &[&str]) -> Self {
163        Self {
164            args: base.iter().map(|s| s.to_string()).collect(),
165        }
166    }
167
168    fn add_opt(&mut self, flag: &str, value: Option<&str>) -> &mut Self {
169        if let Some(v) = value {
170            self.args.push(flag.to_string());
171            self.args.push(v.to_string());
172        }
173        self
174    }
175
176    fn add_opt_num<T: ToString>(&mut self, flag: &str, value: Option<T>) -> &mut Self {
177        if let Some(v) = value {
178            self.args.push(flag.to_string());
179            self.args.push(v.to_string());
180        }
181        self
182    }
183
184    fn add_flag(&mut self, flag: &str, value: Option<bool>) -> &mut Self {
185        if value == Some(true) {
186            self.args.push(flag.to_string());
187        }
188        self
189    }
190
191    fn build(&self) -> Vec<&str> {
192        self.args.iter().map(|s| s.as_str()).collect()
193    }
194}
195
196/// Convert JSON result to MCP CallToolResult
197fn json_result(result: serde_json::Value) -> CallToolResult {
198    let json_str = serde_json::to_string_pretty(&result)
199        .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize result: {}\"}}", e));
200    CallToolResult::success(vec![Content::text(json_str)])
201}
202
203impl KanbanMcpServer {
204    const DEFAULT_RETRY_COUNT: u32 = 3;
205
206    pub fn new(data_file: &str) -> Self {
207        Self {
208            executor: Arc::new(CliExecutor::new(data_file.to_string())),
209            tool_router: Self::tool_router(),
210        }
211    }
212}
213
214// ============================================================================
215// McpTools Trait Implementation (business logic)
216// ============================================================================
217
218// Read operations (list_*, get_*) use execute() without retry because they are
219// idempotent and don't modify state. Write operations use execute_with_retry()
220// to handle transient file conflicts from concurrent access.
221#[async_trait]
222impl McpTools for KanbanMcpServer {
223    // Board Operations
224
225    async fn create_board(
226        &self,
227        name: String,
228        card_prefix: Option<String>,
229    ) -> Result<CallToolResult, McpError> {
230        let mut builder = ArgsBuilder::new(&["board", "create", "--name", &name]);
231        builder.add_opt("--card-prefix", card_prefix.as_deref());
232        let result: serde_json::Value = self
233            .executor
234            .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT)
235            .await?;
236        Ok(json_result(result))
237    }
238
239    async fn list_boards(&self) -> Result<CallToolResult, McpError> {
240        let result: serde_json::Value = self.executor.execute(&["board", "list"]).await?;
241        Ok(json_result(result))
242    }
243
244    async fn get_board(&self, board_id: String) -> Result<CallToolResult, McpError> {
245        let result: serde_json::Value = self.executor.execute(&["board", "get", &board_id]).await?;
246        Ok(json_result(result))
247    }
248
249    async fn delete_board(&self, board_id: String) -> Result<CallToolResult, McpError> {
250        let result: serde_json::Value = self
251            .executor
252            .execute_with_retry(&["board", "delete", &board_id], Self::DEFAULT_RETRY_COUNT)
253            .await?;
254        Ok(json_result(result))
255    }
256
257    // Column Operations
258
259    async fn create_column(
260        &self,
261        board_id: String,
262        name: String,
263        position: Option<i32>,
264    ) -> Result<CallToolResult, McpError> {
265        let mut builder =
266            ArgsBuilder::new(&["column", "create", "--board-id", &board_id, "--name", &name]);
267        builder.add_opt_num("--position", position);
268        let result: serde_json::Value = self
269            .executor
270            .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT)
271            .await?;
272        Ok(json_result(result))
273    }
274
275    async fn list_columns(&self, board_id: String) -> Result<CallToolResult, McpError> {
276        let result: serde_json::Value = self
277            .executor
278            .execute(&["column", "list", "--board-id", &board_id])
279            .await?;
280        Ok(json_result(result))
281    }
282
283    async fn delete_column(&self, column_id: String) -> Result<CallToolResult, McpError> {
284        let result: serde_json::Value = self
285            .executor
286            .execute_with_retry(&["column", "delete", &column_id], Self::DEFAULT_RETRY_COUNT)
287            .await?;
288        Ok(json_result(result))
289    }
290
291    // Card Operations
292
293    async fn create_card(&self, params: CreateCardParams) -> Result<CallToolResult, McpError> {
294        let mut builder = ArgsBuilder::new(&[
295            "card",
296            "create",
297            "--board-id",
298            &params.board_id,
299            "--column-id",
300            &params.column_id,
301            "--title",
302            &params.title,
303        ]);
304        builder
305            .add_opt("--description", params.description.as_deref())
306            .add_opt("--priority", params.priority.as_deref())
307            .add_opt_num("--points", params.points)
308            .add_opt("--due-date", params.due_date.as_deref());
309        let result: serde_json::Value = self
310            .executor
311            .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT)
312            .await?;
313        Ok(json_result(result))
314    }
315
316    async fn list_cards(
317        &self,
318        board_id: Option<String>,
319        column_id: Option<String>,
320        sprint_id: Option<String>,
321    ) -> Result<CallToolResult, McpError> {
322        let mut builder = ArgsBuilder::new(&["card", "list"]);
323        builder
324            .add_opt("--board-id", board_id.as_deref())
325            .add_opt("--column-id", column_id.as_deref())
326            .add_opt("--sprint-id", sprint_id.as_deref());
327        let result: serde_json::Value = self.executor.execute(&builder.build()).await?;
328        Ok(json_result(result))
329    }
330
331    async fn get_card(&self, card_id: String) -> Result<CallToolResult, McpError> {
332        let result: serde_json::Value = self.executor.execute(&["card", "get", &card_id]).await?;
333        Ok(json_result(result))
334    }
335
336    async fn move_card(
337        &self,
338        card_id: String,
339        column_id: String,
340        position: Option<i32>,
341    ) -> Result<CallToolResult, McpError> {
342        let mut builder = ArgsBuilder::new(&["card", "move", &card_id, "--column-id", &column_id]);
343        builder.add_opt_num("--position", position);
344        let result: serde_json::Value = self
345            .executor
346            .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT)
347            .await?;
348        Ok(json_result(result))
349    }
350
351    async fn update_card(&self, params: UpdateCardParams) -> Result<CallToolResult, McpError> {
352        let mut builder = ArgsBuilder::new(&["card", "update", &params.card_id]);
353        builder
354            .add_opt("--title", params.title.as_deref())
355            .add_opt("--description", params.description.as_deref())
356            .add_flag("--clear-description", params.clear_description)
357            .add_opt("--priority", params.priority.as_deref())
358            .add_opt("--status", params.status.as_deref())
359            .add_opt("--due-date", params.due_date.as_deref())
360            .add_opt_num("--points", params.points)
361            .add_flag("--clear-due-date", params.clear_due_date)
362            .add_flag("--clear-points", params.clear_points);
363        let result: serde_json::Value = self
364            .executor
365            .execute_with_retry(&builder.build(), Self::DEFAULT_RETRY_COUNT)
366            .await?;
367        Ok(json_result(result))
368    }
369
370    async fn archive_card(&self, card_id: String) -> Result<CallToolResult, McpError> {
371        let result: serde_json::Value = self
372            .executor
373            .execute_with_retry(&["card", "archive", &card_id], Self::DEFAULT_RETRY_COUNT)
374            .await?;
375        Ok(json_result(result))
376    }
377
378    async fn delete_card(&self, card_id: String) -> Result<CallToolResult, McpError> {
379        let result: serde_json::Value = self
380            .executor
381            .execute_with_retry(&["card", "delete", &card_id], Self::DEFAULT_RETRY_COUNT)
382            .await?;
383        Ok(json_result(result))
384    }
385}
386
387// ============================================================================
388// MCP Tool Wrappers (thin layer exposing trait methods as MCP tools)
389// ============================================================================
390
391#[tool_router]
392impl KanbanMcpServer {
393    // Board Operations
394
395    #[tool(description = "Create a new kanban board")]
396    async fn tool_create_board(
397        &self,
398        Parameters(req): Parameters<CreateBoardRequest>,
399    ) -> Result<CallToolResult, McpError> {
400        McpTools::create_board(self, req.name, req.card_prefix).await
401    }
402
403    #[tool(description = "List all kanban boards")]
404    async fn tool_list_boards(&self) -> Result<CallToolResult, McpError> {
405        McpTools::list_boards(self).await
406    }
407
408    #[tool(description = "Get a specific board by ID")]
409    async fn tool_get_board(
410        &self,
411        Parameters(req): Parameters<GetBoardRequest>,
412    ) -> Result<CallToolResult, McpError> {
413        McpTools::get_board(self, req.board_id).await
414    }
415
416    #[tool(description = "Delete a board and all its columns, cards, and sprints")]
417    async fn tool_delete_board(
418        &self,
419        Parameters(req): Parameters<DeleteBoardRequest>,
420    ) -> Result<CallToolResult, McpError> {
421        McpTools::delete_board(self, req.board_id).await
422    }
423
424    // Column Operations
425
426    #[tool(description = "Create a new column in a board")]
427    async fn tool_create_column(
428        &self,
429        Parameters(req): Parameters<CreateColumnRequest>,
430    ) -> Result<CallToolResult, McpError> {
431        McpTools::create_column(self, req.board_id, req.name, req.position).await
432    }
433
434    #[tool(description = "List all columns in a board")]
435    async fn tool_list_columns(
436        &self,
437        Parameters(req): Parameters<ListColumnsRequest>,
438    ) -> Result<CallToolResult, McpError> {
439        McpTools::list_columns(self, req.board_id).await
440    }
441
442    #[tool(description = "Delete a column and all its cards")]
443    async fn tool_delete_column(
444        &self,
445        Parameters(req): Parameters<DeleteColumnRequest>,
446    ) -> Result<CallToolResult, McpError> {
447        McpTools::delete_column(self, req.column_id).await
448    }
449
450    // Card Operations
451
452    #[tool(description = "Create a new card in a column")]
453    async fn tool_create_card(
454        &self,
455        Parameters(req): Parameters<CreateCardRequest>,
456    ) -> Result<CallToolResult, McpError> {
457        McpTools::create_card(
458            self,
459            CreateCardParams {
460                board_id: req.board_id,
461                column_id: req.column_id,
462                title: req.title,
463                description: req.description,
464                priority: req.priority,
465                points: req.points,
466                due_date: req.due_date,
467            },
468        )
469        .await
470    }
471
472    #[tool(description = "List cards with optional filters")]
473    async fn tool_list_cards(
474        &self,
475        Parameters(req): Parameters<ListCardsRequest>,
476    ) -> Result<CallToolResult, McpError> {
477        McpTools::list_cards(self, req.board_id, req.column_id, req.sprint_id).await
478    }
479
480    #[tool(description = "Get a specific card by ID")]
481    async fn tool_get_card(
482        &self,
483        Parameters(req): Parameters<GetCardRequest>,
484    ) -> Result<CallToolResult, McpError> {
485        McpTools::get_card(self, req.card_id).await
486    }
487
488    #[tool(description = "Move a card to a different column")]
489    async fn tool_move_card(
490        &self,
491        Parameters(req): Parameters<MoveCardRequest>,
492    ) -> Result<CallToolResult, McpError> {
493        McpTools::move_card(self, req.card_id, req.column_id, req.position).await
494    }
495
496    #[tool(
497        description = "Update a card's properties (title, description, priority, status, due_date, points)"
498    )]
499    async fn tool_update_card(
500        &self,
501        Parameters(req): Parameters<UpdateCardRequest>,
502    ) -> Result<CallToolResult, McpError> {
503        McpTools::update_card(
504            self,
505            UpdateCardParams {
506                card_id: req.card_id,
507                title: req.title,
508                description: req.description,
509                clear_description: req.clear_description,
510                priority: req.priority,
511                status: req.status,
512                due_date: req.due_date,
513                clear_due_date: req.clear_due_date,
514                points: req.points,
515                clear_points: req.clear_points,
516            },
517        )
518        .await
519    }
520
521    #[tool(description = "Archive a card (move to archive, can be restored later)")]
522    async fn tool_archive_card(
523        &self,
524        Parameters(req): Parameters<ArchiveCardRequest>,
525    ) -> Result<CallToolResult, McpError> {
526        McpTools::archive_card(self, req.card_id).await
527    }
528
529    #[tool(description = "Delete a card permanently")]
530    async fn tool_delete_card(
531        &self,
532        Parameters(req): Parameters<DeleteCardRequest>,
533    ) -> Result<CallToolResult, McpError> {
534        McpTools::delete_card(self, req.card_id).await
535    }
536}
537
538// ============================================================================
539// MCP Server Handler
540// ============================================================================
541
542#[tool_handler]
543impl ServerHandler for KanbanMcpServer {
544    fn get_info(&self) -> ServerInfo {
545        ServerInfo {
546            protocol_version: ProtocolVersion::V_2024_11_05,
547            capabilities: ServerCapabilities::builder().enable_tools().build(),
548            server_info: Implementation::from_build_env(),
549            instructions: Some(
550                "Kanban MCP Server - Manage your kanban boards, columns, and cards through MCP. \
551                 This server delegates to the kanban CLI for all operations."
552                    .to_string(),
553            ),
554        }
555    }
556}