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#[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#[derive(Clone)]
151pub struct KanbanMcpServer {
152 executor: Arc<CliExecutor>,
153 tool_router: ToolRouter<Self>,
154}
155
156struct 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
196fn 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#[async_trait]
222impl McpTools for KanbanMcpServer {
223 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 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 async fn create_card(&self, params: CreateCardParams) -> Result<CallToolResult, McpError> {
294 let mut builder = ArgsBuilder::new(&[
295 "card",
296 "create",
297 "--board-id",
298 ¶ms.board_id,
299 "--column-id",
300 ¶ms.column_id,
301 "--title",
302 ¶ms.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", ¶ms.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#[tool_router]
392impl KanbanMcpServer {
393 #[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 #[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 #[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#[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}