diff --git a/.gitignore b/.gitignore index 59dfb328..d36d0e88 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,6 @@ coverage/ frontend/dist backend/bindings -build_codesign_release.sh +build-npm-package-codesign.sh npx-cli/dist \ No newline at end of file diff --git a/backend/.sqlx/query-7193dead2b112b137880482fe8e8c822c67ef6692e0456683331a438a4aa002f.json b/backend/.sqlx/query-7193dead2b112b137880482fe8e8c822c67ef6692e0456683331a438a4aa002f.json new file mode 100644 index 00000000..02229377 --- /dev/null +++ b/backend/.sqlx/query-7193dead2b112b137880482fe8e8c822c67ef6692e0456683331a438a4aa002f.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE project_id = $1 AND title = $2\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "7193dead2b112b137880482fe8e8c822c67ef6692e0456683331a438a4aa002f" +} diff --git a/backend/Cargo.toml b/backend/Cargo.toml index bec05d2f..5c552f5b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -3,6 +3,7 @@ name = "vibe-kanban" version = "0.1.0" edition = "2021" default-run = "vibe-kanban" +build = "build.rs" [lib] name = "vibe_kanban" @@ -35,6 +36,8 @@ open = "5.3.2" ignore = "0.4" command-group = { version = "5.0", features = ["with-tokio"] } openssl-sys = { workspace = true } +rmcp = { version = "0.1.5", features = ["server", "transport-io"] } +schemars = "0.8" [build-dependencies] ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } diff --git a/backend/build.rs b/backend/build.rs index 87210b10..c33e92f8 100644 --- a/backend/build.rs +++ b/backend/build.rs @@ -1,4 +1,17 @@ +use std::{fs, path::Path}; + fn main() { - // Tell cargo to rerun build script if models change - println!("cargo:rerun-if-changed=src/models/"); + // Create frontend/dist directory if it doesn't exist + let dist_path = Path::new("../frontend/dist"); + if !dist_path.exists() { + println!("cargo:warning=Creating dummy frontend/dist directory for compilation"); + fs::create_dir_all(dist_path).unwrap(); + + // Create a dummy index.html + let dummy_html = r#" +Build frontend first +

Please build the frontend

"#; + + fs::write(dist_path.join("index.html"), dummy_html).unwrap(); + } } diff --git a/backend/src/bin/mcp_task_server.rs b/backend/src/bin/mcp_task_server.rs new file mode 100644 index 00000000..116dfd48 --- /dev/null +++ b/backend/src/bin/mcp_task_server.rs @@ -0,0 +1,34 @@ +use std::str::FromStr; + +use rmcp::{transport::stdio, ServiceExt}; +use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; +use vibe_kanban::{mcp::task_server::TaskServer, utils::asset_dir}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter("debug") + .with_writer(std::io::stderr) + .init(); + + tracing::debug!("[MCP] Starting MCP task server..."); + + // Database connection + let database_url = format!( + "sqlite://{}", + asset_dir().join("db.sqlite").to_string_lossy() + ); + + let options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(false); + let pool = SqlitePool::connect_with(options).await?; + + let service = TaskServer::new(pool) + .serve(stdio()) + .await + .inspect_err(|e| { + tracing::error!("serving error: {:?}", e); + })?; + + service.waiting().await?; + Ok(()) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index dcfaaa07..23860c8a 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -2,6 +2,7 @@ pub mod app_state; pub mod execution_monitor; pub mod executor; pub mod executors; +pub mod mcp; pub mod models; pub mod routes; pub mod utils; diff --git a/backend/src/main.rs b/backend/src/main.rs index 686331e3..1a832fd7 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -17,6 +17,7 @@ mod app_state; mod execution_monitor; mod executor; mod executors; +mod mcp; mod models; mod routes; mod utils; diff --git a/backend/src/mcp/mod.rs b/backend/src/mcp/mod.rs new file mode 100644 index 00000000..8420256b --- /dev/null +++ b/backend/src/mcp/mod.rs @@ -0,0 +1 @@ +pub mod task_server; diff --git a/backend/src/mcp/task_server.rs b/backend/src/mcp/task_server.rs new file mode 100644 index 00000000..de9f1659 --- /dev/null +++ b/backend/src/mcp/task_server.rs @@ -0,0 +1,1333 @@ +use rmcp::{ + model::{ + CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, + }, + schemars, tool, Error as RmcpError, ServerHandler, +}; +use serde::{Deserialize, Serialize}; +use serde_json; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::models::{ + project::{CreateProject, Project}, + task::{CreateTask, Task, TaskStatus}, +}; + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateTaskRequest { + #[schemars(description = "The ID of the project to create the task in")] + pub project_id: String, + #[schemars(description = "The title of the task")] + pub title: String, + #[schemars(description = "Optional description of the task")] + pub description: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListProjectsRequest { + // Empty for now, but we can add filtering options later +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct CreateTaskResponse { + pub success: bool, + pub task_id: String, + pub message: String, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ProjectSummary { + #[schemars(description = "The unique identifier of the project")] + pub id: String, + #[schemars(description = "The name of the project")] + pub name: String, + #[schemars(description = "The path to the git repository")] + pub git_repo_path: String, + #[schemars(description = "Optional setup script for the project")] + pub setup_script: Option, + #[schemars(description = "Optional development script for the project")] + pub dev_script: Option, + #[schemars(description = "Current git branch (if available)")] + pub current_branch: Option, + #[schemars(description = "When the project was created")] + pub created_at: String, + #[schemars(description = "When the project was last updated")] + pub updated_at: String, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ListProjectsResponse { + pub success: bool, + pub projects: Vec, + pub count: usize, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListTasksRequest { + #[schemars(description = "The ID of the project to list tasks from")] + pub project_id: String, + #[schemars( + description = "Optional status filter: 'todo', 'inprogress', 'inreview', 'done', 'cancelled'" + )] + pub status: Option, + #[schemars(description = "Maximum number of tasks to return (default: 50)")] + pub limit: Option, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct TaskSummary { + #[schemars(description = "The unique identifier of the task")] + pub id: String, + #[schemars(description = "The title of the task")] + pub title: String, + #[schemars(description = "Optional description of the task")] + pub description: Option, + #[schemars(description = "Current status of the task")] + pub status: String, + #[schemars(description = "When the task was created")] + pub created_at: String, + #[schemars(description = "When the task was last updated")] + pub updated_at: String, + #[schemars(description = "Whether the task has an in-progress execution attempt")] + pub has_in_progress_attempt: Option, + #[schemars(description = "Whether the task has a merged execution attempt")] + pub has_merged_attempt: Option, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ListTasksResponse { + pub success: bool, + pub tasks: Vec, + pub count: usize, + pub project_id: String, + pub project_name: Option, + pub applied_filters: ListTasksFilters, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ListTasksFilters { + pub status: Option, + pub limit: i32, +} + +fn parse_task_status(status_str: &str) -> Option { + match status_str.to_lowercase().as_str() { + "todo" => Some(TaskStatus::Todo), + "inprogress" | "in-progress" | "in_progress" => Some(TaskStatus::InProgress), + "inreview" | "in-review" | "in_review" => Some(TaskStatus::InReview), + "done" | "completed" => Some(TaskStatus::Done), + "cancelled" | "canceled" => Some(TaskStatus::Cancelled), + _ => None, + } +} + +fn task_status_to_string(status: &TaskStatus) -> String { + match status { + TaskStatus::Todo => "todo".to_string(), + TaskStatus::InProgress => "in-progress".to_string(), + TaskStatus::InReview => "in-review".to_string(), + TaskStatus::Done => "done".to_string(), + TaskStatus::Cancelled => "cancelled".to_string(), + } +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateTaskRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The ID of the task to update")] + pub task_id: String, + #[schemars(description = "New title for the task")] + pub title: Option, + #[schemars(description = "New description for the task")] + pub description: Option, + #[schemars(description = "New status: 'todo', 'inprogress', 'inreview', 'done', 'cancelled'")] + pub status: Option, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct UpdateTaskResponse { + pub success: bool, + pub message: String, + pub task: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteTaskRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The ID of the task to delete")] + pub task_id: String, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct DeleteTaskResponse { + pub success: bool, + pub message: String, + pub deleted_task_id: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CompleteTaskRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The title of the task to complete (will search for exact match)")] + pub task_title: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct SetTaskStatusRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The title of the task to update")] + pub task_title: String, + #[schemars( + description = "New status: 'todo', 'in-progress', 'in-review', 'done', 'cancelled'" + )] + pub status: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateTaskTitleRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "Current title of the task")] + pub current_title: String, + #[schemars(description = "New title for the task")] + pub new_title: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateTaskDescriptionRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The title of the task to update")] + pub task_title: String, + #[schemars(description = "New description for the task")] + pub description: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteTaskByTitleRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The title of the task to delete")] + pub task_title: String, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct SimpleTaskResponse { + pub success: bool, + pub message: String, + pub task_title: String, + pub new_status: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetTaskRequest { + #[schemars(description = "The ID of the project containing the task")] + pub project_id: String, + #[schemars(description = "The ID of the task to retrieve")] + pub task_id: String, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct GetTaskResponse { + pub success: bool, + pub task: Option, + pub project_name: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateProjectRequest { + #[schemars(description = "Name of the project")] + pub name: String, + #[schemars(description = "Path to the git repository")] + pub git_repo_path: String, + #[schemars(description = "Whether to use existing repo (true) or create new (false)")] + pub use_existing_repo: Option, + #[schemars(description = "Optional setup script command")] + pub setup_script: Option, + #[schemars(description = "Optional development script command")] + pub dev_script: Option, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct CreateProjectResponse { + pub success: bool, + pub message: String, + pub project_id: Option, + pub project: Option, +} + +#[derive(Debug, Clone)] +pub struct TaskServer { + pub pool: SqlitePool, +} + +impl TaskServer { + #[allow(dead_code)] + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[tool(tool_box)] +impl TaskServer { + #[tool(description = "Create a new task in a project")] + async fn create_task( + &self, + #[tool(aggr)] CreateTaskRequest { + project_id, + title, + description, + }: CreateTaskRequest, + ) -> Result { + // Parse project_id from string to UUID + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format. Must be a valid UUID.", + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Invalid project ID format".to_string()), + )])); + } + }; + + // Check if project exists + match Project::exists(&self.pool, project_uuid).await { + Ok(false) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Project not found", + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Project not found".to_string()), + )])); + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to check project existence", + "details": e.to_string(), + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Database error".to_string()), + )])); + } + Ok(true) => {} + } + + let task_id = Uuid::new_v4(); + let create_task_data = CreateTask { + project_id: project_uuid, + title: title.clone(), + description: description.clone(), + }; + + match Task::create(&self.pool, &create_task_data, task_id).await { + Ok(_task) => { + let success_response = CreateTaskResponse { + success: true, + task_id: task_id.to_string(), + message: "Task created successfully".to_string(), + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&success_response) + .unwrap_or_else(|_| "Task created successfully".to_string()), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to create task", + "details": e.to_string(), + "project_id": project_id, + "title": title + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Failed to create task".to_string()), + )])) + } + } + } + + #[tool(description = "List all available projects")] + async fn list_projects( + &self, + #[tool(aggr)] _request: ListProjectsRequest, + ) -> Result { + match Project::find_all(&self.pool).await { + Ok(projects) => { + let count = projects.len(); + let project_summaries: Vec = projects + .into_iter() + .map(|project| { + let project_with_branch = project.with_branch_info(); + ProjectSummary { + id: project_with_branch.id.to_string(), + name: project_with_branch.name, + git_repo_path: project_with_branch.git_repo_path, + setup_script: project_with_branch.setup_script, + dev_script: project_with_branch.dev_script, + current_branch: project_with_branch.current_branch, + created_at: project_with_branch.created_at.to_rfc3339(), + updated_at: project_with_branch.updated_at.to_rfc3339(), + } + }) + .collect(); + + let response = ListProjectsResponse { + success: true, + projects: project_summaries, + count, + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response) + .unwrap_or_else(|_| "Failed to serialize projects".to_string()), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to retrieve projects", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Database error".to_string()), + )])) + } + } + } + + #[tool(description = "List tasks in a project with optional filtering and execution status")] + async fn list_tasks( + &self, + #[tool(aggr)] ListTasksRequest { + project_id, + status, + limit, + }: ListTasksRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format. Must be a valid UUID.", + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Invalid project ID format".to_string()), + )])); + } + }; + + let status_filter = if let Some(ref status_str) = status { + match parse_task_status(status_str) { + Some(status) => Some(status), + None => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid status filter. Valid values: 'todo', 'inprogress', 'inreview', 'done', 'cancelled'", + "provided_status": status_str + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Invalid status filter".to_string()), + )])); + } + } + } else { + None + }; + + let project = match Project::find_by_id(&self.pool, project_uuid).await { + Ok(Some(project)) => project, + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Project not found", + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Project not found".to_string()), + )])); + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to check project existence", + "details": e.to_string(), + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Database error".to_string()), + )])); + } + }; + + let task_limit = limit.unwrap_or(50).clamp(1, 200); // Reasonable limits + + let tasks_result = + Task::find_by_project_id_with_attempt_status(&self.pool, project_uuid).await; + + match tasks_result { + Ok(tasks) => { + let filtered_tasks: Vec<_> = tasks + .into_iter() + .filter(|task| { + if let Some(ref filter_status) = status_filter { + &task.status == filter_status + } else { + true + } + }) + .take(task_limit as usize) + .collect(); + + let task_summaries: Vec = filtered_tasks + .into_iter() + .map(|task| TaskSummary { + id: task.id.to_string(), + title: task.title, + description: task.description, + status: task_status_to_string(&task.status), + created_at: task.created_at.to_rfc3339(), + updated_at: task.updated_at.to_rfc3339(), + has_in_progress_attempt: Some(task.has_in_progress_attempt), + has_merged_attempt: Some(task.has_merged_attempt), + }) + .collect(); + + let count = task_summaries.len(); + let response = ListTasksResponse { + success: true, + tasks: task_summaries, + count, + project_id: project_id.clone(), + project_name: Some(project.name), + applied_filters: ListTasksFilters { + status: status.clone(), + limit: task_limit, + }, + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response) + .unwrap_or_else(|_| "Failed to serialize tasks".to_string()), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to retrieve tasks", + "details": e.to_string(), + "project_id": project_id + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response) + .unwrap_or_else(|_| "Database error".to_string()), + )])) + } + } + } + + #[tool(description = "Update an existing task's title, description, or status")] + async fn update_task( + &self, + #[tool(aggr)] UpdateTaskRequest { + project_id, + task_id, + title, + description, + status, + }: UpdateTaskRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format. Must be a valid UUID.", + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let task_uuid = match Uuid::parse_str(&task_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid task ID format. Must be a valid UUID.", + "task_id": task_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let status_enum = if let Some(ref status_str) = status { + match parse_task_status(status_str) { + Some(status) => Some(status), + None => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid status. Valid values: 'todo', 'inprogress', 'inreview', 'done', 'cancelled'", + "provided_status": status_str + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + } + } else { + None + }; + + let current_task = + match Task::find_by_id_and_project_id(&self.pool, task_uuid, project_uuid).await { + Ok(Some(task)) => task, + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Task not found in the specified project", + "task_id": task_id, + "project_id": project_id + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to retrieve task", + "details": e.to_string() + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let new_title = title.unwrap_or(current_task.title); + let new_description = description.or(current_task.description); + let new_status = status_enum.unwrap_or(current_task.status); + + match Task::update( + &self.pool, + task_uuid, + project_uuid, + new_title, + new_description, + new_status, + ) + .await + { + Ok(updated_task) => { + let task_summary = TaskSummary { + id: updated_task.id.to_string(), + title: updated_task.title, + description: updated_task.description, + status: task_status_to_string(&updated_task.status), + created_at: updated_task.created_at.to_rfc3339(), + updated_at: updated_task.updated_at.to_rfc3339(), + has_in_progress_attempt: None, + has_merged_attempt: None, + }; + + let response = UpdateTaskResponse { + success: true, + message: "Task updated successfully".to_string(), + task: Some(task_summary), + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to update task", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Mark a task as completed by its title")] + async fn complete_task( + &self, + #[tool(aggr)] CompleteTaskRequest { + project_id, + task_title, + }: CompleteTaskRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format. Please use the project ID from list_projects.", + "hint": "Try using 'list_projects' first to get the correct project ID" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + match Task::find_task_by_title(&self.pool, project_uuid, &task_title).await { + Ok(Some(task)) => { + match Task::update_status(&self.pool, task.id, project_uuid, TaskStatus::Done).await + { + Ok(_) => { + let response = SimpleTaskResponse { + success: true, + message: format!("Task '{}' marked as completed", task_title), + task_title: task_title.clone(), + new_status: Some("done".to_string()), + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to update task status", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": format!("Task with title '{}' not found in project", task_title), + "hint": "Use 'list_tasks' to see available tasks in the project" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Database error while searching for task", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Set a task's status by its title")] + async fn set_task_status( + &self, + #[tool(aggr)] SetTaskStatusRequest { + project_id, + task_title, + status, + }: SetTaskStatusRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format", + "hint": "Use 'list_projects' to get the correct project ID" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let status_enum = match parse_task_status(&status) { + Some(s) => s, + None => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid status value", + "provided": status, + "valid_options": ["todo", "in-progress", "in-review", "done", "cancelled"], + "hint": "Use one of the valid status options" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + match Task::find_task_by_title(&self.pool, project_uuid, &task_title).await { + Ok(Some(task)) => { + match Task::update_status(&self.pool, task.id, project_uuid, status_enum).await { + Ok(_) => { + let response = SimpleTaskResponse { + success: true, + message: format!( + "Task '{}' status updated to '{}'", + task_title, status + ), + task_title: task_title.clone(), + new_status: Some(status.clone()), + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to update task status", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": format!("Task '{}' not found in project", task_title), + "hint": "Use 'list_tasks' to see available tasks" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Database error", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Delete a task by its title")] + async fn delete_task_by_title( + &self, + #[tool(aggr)] DeleteTaskByTitleRequest { + project_id, + task_title, + }: DeleteTaskByTitleRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format", + "hint": "Use 'list_projects' to get the correct project ID" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + match Task::find_task_by_title(&self.pool, project_uuid, &task_title).await { + Ok(Some(task)) => match Task::delete(&self.pool, task.id, project_uuid).await { + Ok(_) => { + let response = SimpleTaskResponse { + success: true, + message: format!("Task '{}' deleted successfully", task_title), + task_title: task_title.clone(), + new_status: None, + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to delete task", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + }, + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": format!("Task '{}' not found in project", task_title), + "hint": "Use 'list_tasks' to see available tasks" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Database error", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Update a task's title by finding it with the current title")] + async fn update_task_title( + &self, + #[tool(aggr)] UpdateTaskTitleRequest { + project_id, + current_title, + new_title, + }: UpdateTaskTitleRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format", + "hint": "Use 'list_projects' to get the correct project ID" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + match Task::find_task_by_title(&self.pool, project_uuid, ¤t_title).await { + Ok(Some(task)) => { + // Update the task with new title, keeping other fields the same + match Task::update( + &self.pool, + task.id, + project_uuid, + new_title.clone(), + task.description, + task.status, + ) + .await + { + Ok(updated_task) => { + let response = serde_json::json!({ + "success": true, + "message": format!("Task title updated from '{}' to '{}'", current_title, new_title), + "old_title": current_title, + "new_title": new_title, + "task_id": updated_task.id.to_string() + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to update task title", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": format!("Task with title '{}' not found in project", current_title), + "hint": "Use 'list_tasks' to see available tasks with exact titles" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Database error while searching for task", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Update a task's description by its title")] + async fn update_task_description( + &self, + #[tool(aggr)] UpdateTaskDescriptionRequest { + project_id, + task_title, + description, + }: UpdateTaskDescriptionRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format", + "hint": "Use 'list_projects' to get the correct project ID" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + match Task::find_task_by_title(&self.pool, project_uuid, &task_title).await { + Ok(Some(task)) => { + // Update the task with new description, keeping other fields the same + match Task::update( + &self.pool, + task.id, + project_uuid, + task.title.clone(), + Some(description.clone()), + task.status, + ) + .await + { + Ok(updated_task) => { + let response = serde_json::json!({ + "success": true, + "message": format!("Description updated for task '{}'", task_title), + "task_title": task_title, + "new_description": description, + "task_id": updated_task.id.to_string() + }); + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to update task description", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + Ok(None) => { + let error_response = serde_json::json!({ + "success": false, + "error": format!("Task with title '{}' not found in project", task_title), + "hint": "Use 'list_tasks' to see available tasks with exact titles" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Database error while searching for task", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Delete a task from a project")] + async fn delete_task( + &self, + #[tool(aggr)] DeleteTaskRequest { + project_id, + task_id, + }: DeleteTaskRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let task_uuid = match Uuid::parse_str(&task_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid task ID format" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + match Task::exists(&self.pool, task_uuid, project_uuid).await { + Ok(true) => { + // Delete the task + match Task::delete(&self.pool, task_uuid, project_uuid).await { + Ok(rows_affected) => { + if rows_affected > 0 { + let response = DeleteTaskResponse { + success: true, + message: "Task deleted successfully".to_string(), + deleted_task_id: Some(task_id), + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } else { + let error_response = serde_json::json!({ + "success": false, + "error": "Task not found or already deleted" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to delete task", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + Ok(false) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Task not found in the specified project" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to check task existence", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Get detailed information about a specific task")] + async fn get_task( + &self, + #[tool(aggr)] GetTaskRequest { + project_id, + task_id, + }: GetTaskRequest, + ) -> Result { + let project_uuid = match Uuid::parse_str(&project_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid project ID format" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let task_uuid = match Uuid::parse_str(&task_id) { + Ok(uuid) => uuid, + Err(_) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Invalid task ID format" + }); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])); + } + }; + + let task_result = + Task::find_by_id_and_project_id(&self.pool, task_uuid, project_uuid).await; + let project_result = Project::find_by_id(&self.pool, project_uuid).await; + + match (task_result, project_result) { + (Ok(Some(task)), Ok(Some(project))) => { + let task_summary = TaskSummary { + id: task.id.to_string(), + title: task.title, + description: task.description, + status: task_status_to_string(&task.status), + created_at: task.created_at.to_rfc3339(), + updated_at: task.updated_at.to_rfc3339(), + has_in_progress_attempt: None, + has_merged_attempt: None, + }; + + let response = GetTaskResponse { + success: true, + task: Some(task_summary), + project_name: Some(project.name), + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + (Ok(None), _) | (_, Ok(None)) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Task or project not found" + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + (Err(e), _) | (_, Err(e)) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to retrieve task or project", + "details": e.to_string() + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } + + #[tool(description = "Create a new project")] + async fn create_project( + &self, + #[tool(aggr)] CreateProjectRequest { + name, + git_repo_path, + use_existing_repo, + setup_script, + dev_script, + }: CreateProjectRequest, + ) -> Result { + let project_id = Uuid::new_v4(); + + let create_project_data = CreateProject { + name: name.clone(), + git_repo_path, + use_existing_repo: use_existing_repo.unwrap_or(true), + setup_script, + dev_script, + }; + + match Project::create(&self.pool, &create_project_data, project_id).await { + Ok(project) => { + let current_branch = project.get_current_branch().ok(); + let project_summary = ProjectSummary { + id: project.id.to_string(), + name: project.name, + git_repo_path: project.git_repo_path, + setup_script: project.setup_script, + dev_script: project.dev_script, + current_branch, + created_at: project.created_at.to_rfc3339(), + updated_at: project.updated_at.to_rfc3339(), + }; + + let response = CreateProjectResponse { + success: true, + message: "Project created successfully".to_string(), + project_id: Some(project.id.to_string()), + project: Some(project_summary), + }; + + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&response).unwrap(), + )])) + } + Err(e) => { + let error_response = serde_json::json!({ + "success": false, + "error": "Failed to create project", + "details": e.to_string(), + "project_name": name + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_response).unwrap(), + )])) + } + } + } +} + +#[tool(tool_box)] +impl ServerHandler for TaskServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder() + .enable_tools() + .build(), + server_info: Implementation { + name: "task-manager".to_string(), + version: "1.0.0".to_string(), + }, + instructions: Some("A task and project management server. AGENT-FRIENDLY TOOLS (use task titles): 'complete_task', 'set_task_status', 'update_task_title', 'update_task_description', 'delete_task_by_title'. STANDARD TOOLS: 'list_projects', 'create_project', 'list_tasks', 'create_task', 'get_task'. ADVANCED TOOLS (use UUIDs): 'update_task', 'delete_task'. Always use exact task titles from list_tasks results.".to_string()), + } + } +} diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index 89753451..fe9f4609 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -244,4 +244,22 @@ impl Task { .await?; Ok(result.is_some()) } + + pub async fn find_task_by_title( + pool: &SqlitePool, + project_id: Uuid, + title: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + Task, + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + FROM tasks + WHERE project_id = $1 AND title = $2 + LIMIT 1"#, + project_id, + title + ) + .fetch_optional(pool) + .await + } } diff --git a/build-npm-package.sh b/build-npm-package.sh new file mode 100755 index 00000000..69b312ca --- /dev/null +++ b/build-npm-package.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e # Exit on any error + +echo "🧹 Cleaning previous builds..." +rm -rf npx-cli/dist +mkdir -p npx-cli/dist/macos-arm64 + +echo "πŸ”¨ Building frontend..." +npm run frontend:build + +echo "πŸ”¨ Building Rust binaries..." +cargo build --release --manifest-path backend/Cargo.toml +cargo build --release --bin mcp_task_server --manifest-path backend/Cargo.toml + +echo "πŸ“¦ Creating distribution package..." + +# Copy the main binary +cp target/release/vibe-kanban vibe-kanban +cp target/release/mcp_task_server vibe-kanban-mcp + +zip vibe-kanban.zip vibe-kanban +zip vibe-kanban-mcp.zip vibe-kanban-mcp + +rm vibe-kanban vibe-kanban-mcp + +mv vibe-kanban.zip npx-cli/dist/macos-arm64/vibe-kanban.zip +mv vibe-kanban-mcp.zip npx-cli/dist/macos-arm64/vibe-kanban-mcp.zip + +echo "βœ… NPM package ready!" +echo "πŸ“ Files created:" +echo " - npx-cli/dist/macos-arm64/vibe-kanban.zip" +echo " - npx-cli/dist/macos-arm64/vibe-kanban-mcp.zip" \ No newline at end of file diff --git a/mcp_test.js b/mcp_test.js new file mode 100644 index 00000000..0ba8a634 --- /dev/null +++ b/mcp_test.js @@ -0,0 +1,374 @@ +const { spawn } = require('child_process'); + +console.error('πŸ”„ Starting MCP server for comprehensive endpoint testing...'); + +// Test configuration +let currentStepIndex = 0; +let messageId = 1; +let testData = { + projectId: null, + taskId: null, + createdProjectId: null, + taskTitle: "Test Task from MCP Script", + updatedTaskTitle: "Updated Test Task Title", + secondTaskTitle: "Second Test Task", + renamedTaskTitle: "Renamed Second Task", +}; + +const testSequence = [ + 'initialize', + 'initialized_notification', + 'list_tools', + 'list_projects', + 'create_project', + 'list_tasks', // empty + 'create_task', + 'get_task', + 'list_tasks', // with task + 'set_task_status', + 'list_tasks', // filtered + 'complete_task', + 'list_tasks', // completed + 'create_task', // second task + 'update_task', // legacy + 'update_task_title', + 'update_task_description', + 'list_tasks', // after updates + 'delete_task_by_title', + 'list_tasks', // final + 'summary' +]; + +const stepHandlers = { + initialize: { + description: 'Initialize MCP connection', + action: () => { + console.log('πŸ“€ Sending initialize request...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0.0"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + initialized_notification: { + description: 'Send initialized notification', + action: () => { + console.log('πŸ“€ Sending initialized notification...'); + mcpProcess.stdin.write('{"jsonrpc": "2.0", "method": "notifications/initialized"}\n'); + // Notifications don't have responses, auto-advance + setTimeout(() => executeNextStep(), 200); + }, + responseHandler: null + }, + + list_tools: { + description: 'List available MCP tools', + action: () => { + console.log('πŸ“€ Sending tools/list...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/list", "params": {}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + list_projects: { + description: 'List all projects', + action: () => { + console.log('πŸ“€ Sending list_projects...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "list_projects", "arguments": {}}}\n`); + }, + responseHandler: (response) => { + try { + const parsedResponse = JSON.parse(response); + if (parsedResponse.result?.content) { + const projectsResponse = JSON.parse(parsedResponse.result.content[0].text); + if (projectsResponse.success && projectsResponse.projects.length > 0) { + testData.projectId = projectsResponse.projects[0].id; + console.log(`πŸ’Ύ Found existing project: ${testData.projectId}`); + } + } + } catch (e) { + console.error('⚠️ Could not parse projects response'); + } + executeNextStep(); + } + }, + + create_project: { + description: 'Create a new test project', + action: () => { + console.log('πŸ“€ Sending create_project...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "create_project", "arguments": {"name": "Test Project from MCP", "git_repo_path": "/tmp/test-project", "use_existing_repo": false, "setup_script": "echo \\"Setup complete\\"", "dev_script": "echo \\"Dev server started\\""}}}\n`); + }, + responseHandler: (response) => { + try { + const parsedResponse = JSON.parse(response); + if (parsedResponse.result?.content) { + const createProjectResponse = JSON.parse(parsedResponse.result.content[0].text); + if (createProjectResponse.success && createProjectResponse.project_id) { + testData.createdProjectId = createProjectResponse.project_id; + console.log(`πŸ’Ύ Created project: ${testData.createdProjectId}`); + } + } + } catch (e) { + console.error('⚠️ Could not parse create project response'); + } + executeNextStep(); + } + }, + + list_tasks: { + description: 'List tasks in project', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + const context = getListTasksContext(); + + console.log(`πŸ“€ Sending list_tasks (${context})...`); + + let args = { project_id: projectToUse }; + + // Add context-specific filters + if (context === 'filtered') { + args.status = 'in-progress'; + } else if (context === 'completed') { + args.status = 'done'; + } else if (context === 'empty') { + args.include_execution_status = true; + } + + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "list_tasks", "arguments": ${JSON.stringify(args)}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + create_task: { + description: 'Create a new task', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + const isSecondTask = getCreateTaskContext() === 'second'; + const title = isSecondTask ? testData.secondTaskTitle : testData.taskTitle; + const description = isSecondTask ? + "This is a second task for testing updates" : + "This task was created during endpoint testing"; + + console.log(`πŸ“€ Sending create_task (${isSecondTask ? 'second task' : 'first task'})...`); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "create_task", "arguments": {"project_id": "${projectToUse}", "title": "${title}", "description": "${description}"}}}\n`); + }, + responseHandler: (response) => { + try { + const parsedResponse = JSON.parse(response); + if (parsedResponse.result?.content) { + const createTaskResponse = JSON.parse(parsedResponse.result.content[0].text); + if (createTaskResponse.success && createTaskResponse.task_id) { + testData.taskId = createTaskResponse.task_id; + console.log(`πŸ’Ύ Created task: ${testData.taskId}`); + } + } + } catch (e) { + console.error('⚠️ Could not parse create task response'); + } + executeNextStep(); + } + }, + + get_task: { + description: 'Get task details by ID', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending get_task...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "get_task", "arguments": {"project_id": "${projectToUse}", "task_id": "${testData.taskId}"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + set_task_status: { + description: 'Set task status (agent-friendly)', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending set_task_status (agent-friendly)...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "set_task_status", "arguments": {"project_id": "${projectToUse}", "task_title": "${testData.taskTitle}", "status": "in-progress"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + complete_task: { + description: 'Complete task (agent-friendly)', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending complete_task (agent-friendly)...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "complete_task", "arguments": {"project_id": "${projectToUse}", "task_title": "${testData.taskTitle}"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + update_task: { + description: 'Update task (legacy UUID method)', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending update_task (legacy method)...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "update_task", "arguments": {"project_id": "${projectToUse}", "task_id": "${testData.taskId}", "title": "${testData.updatedTaskTitle}", "description": "Updated description via legacy method", "status": "in-review"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + update_task_title: { + description: 'Update task title (agent-friendly)', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending update_task_title (agent-friendly)...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "update_task_title", "arguments": {"project_id": "${projectToUse}", "current_title": "${testData.secondTaskTitle}", "new_title": "${testData.renamedTaskTitle}"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + update_task_description: { + description: 'Update task description (agent-friendly)', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending update_task_description (agent-friendly)...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "update_task_description", "arguments": {"project_id": "${projectToUse}", "task_title": "${testData.renamedTaskTitle}", "description": "This description was updated using the agent-friendly endpoint"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + delete_task_by_title: { + description: 'Delete task by title (agent-friendly)', + action: () => { + const projectToUse = testData.createdProjectId || testData.projectId; + console.log('πŸ“€ Sending delete_task_by_title (agent-friendly)...'); + mcpProcess.stdin.write(`{"jsonrpc": "2.0", "id": ${messageId++}, "method": "tools/call", "params": {"name": "delete_task_by_title", "arguments": {"project_id": "${projectToUse}", "task_title": "${testData.renamedTaskTitle}"}}}\n`); + }, + responseHandler: () => { + executeNextStep(); + } + }, + + summary: { + description: 'Test completion summary', + action: () => { + console.log('βœ… All endpoint tests completed successfully!'); + console.log(''); + console.log('πŸ“Š Test Summary:'); + console.log(` - Project ID used: ${testData.projectId || 'N/A'}`); + console.log(` - Created project: ${testData.createdProjectId || 'N/A'}`); + console.log(` - Task ID tested: ${testData.taskId || 'N/A'}`); + console.log(` - Task title: ${testData.taskTitle}`); + console.log(''); + console.log('🎯 Agent-Friendly Endpoints Tested:'); + console.log(' βœ… set_task_status - Change task status by title'); + console.log(' βœ… complete_task - Mark task done by title'); + console.log(' βœ… update_task_title - Change task title'); + console.log(' βœ… update_task_description - Update task description'); + console.log(' βœ… delete_task_by_title - Delete task by title'); + console.log(''); + console.log('πŸ”§ Legacy Endpoints Tested:'); + console.log(' βœ… update_task - Update task by ID (more complex)'); + console.log(' βœ… get_task - Get task details by ID'); + console.log(''); + console.log('πŸŽ‰ All MCP endpoints are working correctly!'); + console.log('πŸ’‘ Agents should prefer the title-based endpoints for easier usage'); + setTimeout(() => mcpProcess.kill(), 500); + }, + responseHandler: null + } +}; + +// Helper functions to determine context +function getListTasksContext() { + const prevSteps = testSequence.slice(0, currentStepIndex); + if (prevSteps[prevSteps.length - 1] === 'create_project') return 'empty'; + if (prevSteps[prevSteps.length - 1] === 'set_task_status') return 'filtered'; + if (prevSteps[prevSteps.length - 1] === 'complete_task') return 'completed'; + if (prevSteps[prevSteps.length - 1] === 'update_task_description') return 'after updates'; + if (prevSteps[prevSteps.length - 1] === 'delete_task_by_title') return 'final'; + return 'with task'; +} + +function getCreateTaskContext() { + const prevSteps = testSequence.slice(0, currentStepIndex); + const createTaskCount = prevSteps.filter(step => step === 'create_task').length; + return createTaskCount > 0 ? 'second' : 'first'; +} + +// Execute current step +function executeCurrentStep() { + if (currentStepIndex >= testSequence.length) { + console.log('⚠️ All steps completed'); + return; + } + + const stepName = testSequence[currentStepIndex]; + const stepHandler = stepHandlers[stepName]; + + if (!stepHandler) { + console.error(`❌ Unknown step: ${stepName}`); + return; + } + + console.log(`πŸ”„ Step ${currentStepIndex + 1}/${testSequence.length}: ${stepHandler.description}`); + + setTimeout(() => { + stepHandler.action(); + }, 100); +} + +// Move to next step +function executeNextStep() { + currentStepIndex++; + executeCurrentStep(); +} + +// Start MCP process +const mcpProcess = spawn('vibe-kanban', ["--mcp"], { + stdio: ['pipe', 'pipe', 'inherit'], +}); + +mcpProcess.stdout.on('data', (data) => { + const response = data.toString().trim(); + const currentStepName = testSequence[currentStepIndex]; + const stepHandler = stepHandlers[currentStepName]; + + console.log(`πŸ“₯ MCP Response (${currentStepName}):`); + console.log(response); + + if (stepHandler?.responseHandler) { + stepHandler.responseHandler(response); + } +}); + +mcpProcess.on('exit', (code) => { + console.error(`πŸ”΄ MCP server exited with code: ${code}`); + process.exit(0); +}); + +mcpProcess.on('error', (error) => { + console.error('❌ MCP server error:', error); + process.exit(1); +}); + +// Start the sequence +setTimeout(() => { + executeCurrentStep(); +}, 500); + +// Safety timeout +setTimeout(() => { + console.error('⏰ Test timeout - killing process'); + mcpProcess.kill(); +}, 45000); \ No newline at end of file diff --git a/npx-cli/bin/cli.js b/npx-cli/bin/cli.js index b96420f2..ac8f1815 100755 --- a/npx-cli/bin/cli.js +++ b/npx-cli/bin/cli.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -const { execSync } = require("child_process"); +const { execSync, spawn } = require("child_process"); const path = require("path"); const fs = require("fs"); @@ -19,9 +19,7 @@ function getPlatformDir() { } else if (platform === "darwin" && arch === "arm64") { return "macos-arm64"; } else { - console.error( - `❌ Unsupported platform: ${platform}-${arch}` - ); + console.error(`❌ Unsupported platform: ${platform}-${arch}`); console.error("Supported platforms:"); console.error(" - Linux x64"); console.error(" - Windows x64"); @@ -31,64 +29,128 @@ function getPlatformDir() { } } -function getBinaryName() { - return platform === "win32" ? "vibe-kanban.exe" : "vibe-kanban"; +function getBinaryName(base_name) { + return platform === "win32" ? `${base_name}.exe` : base_name; } -try { - const platformDir = getPlatformDir(); - const extractDir = path.join(__dirname, "..", "dist", platformDir); - const zipName = "vibe-kanban.zip"; +const platformDir = getPlatformDir(); +const extractDir = path.join(__dirname, "..", "dist", platformDir); + +const isMcpMode = process.argv.includes("--mcp"); + +if (!fs.existsSync(extractDir)) { + fs.mkdirSync(extractDir, { recursive: true }); +} + +if (isMcpMode) { + const baseName = "vibe-kanban-mcp"; + const binaryName = getBinaryName(baseName); + const binaryPath = path.join(extractDir, binaryName); + const zipName = `${baseName}.zip`; const zipPath = path.join(extractDir, zipName); + // Check if binary exists, delete if it does + if (fs.existsSync(binaryPath)) { + fs.unlinkSync(binaryPath); + } + // Check if zip file exists if (!fs.existsSync(zipPath)) { - console.error(`❌ vibe-kanban.zip not found at: ${zipPath}`); + // console.error(`❌ ${zipName} not found at: ${zipPath}`); + // console.error(`Current platform: ${platform}-${arch} (${platformDir})`); + process.exit(1); + } + + // Unzip the file + // console.log(`πŸ“¦ Extracting ${baseName}...`); + if (platform === "win32") { + // Use PowerShell on Windows + execSync( + `powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force"`, + { stdio: "inherit" } + ); + } else { + // Use unzip on Unix-like systems + execSync(`unzip -qq -o "${zipPath}" -d "${extractDir}"`, { + stdio: "inherit", + }); + } + + // Make sure it's executable + try { + fs.chmodSync(binaryPath, 0o755); + } catch (error) { + // console.error( + // "⚠️ Warning: Could not set executable permissions:", + // error.message + // ); + } + + // Launch MCP server + // console.error(`πŸš€ Starting ${baseName}...`); + + const mcpProcess = spawn(binaryPath, [], { + stdio: ["pipe", "pipe", "inherit"], // stdin/stdout for MCP, stderr for logs + }); + + // Forward stdin to MCP server + process.stdin.pipe(mcpProcess.stdin); + + // Forward MCP server stdout to our stdout + mcpProcess.stdout.pipe(process.stdout); + + // Handle process termination + mcpProcess.on("exit", (code) => { + process.exit(code || 0); + }); + + mcpProcess.on("error", (error) => { + console.error("❌ MCP server error:", error.message); + process.exit(1); + }); + + // Handle Ctrl+C + process.on("SIGINT", () => { + console.error("\nπŸ›‘ Shutting down MCP server..."); + mcpProcess.kill("SIGINT"); + }); + + process.on("SIGTERM", () => { + mcpProcess.kill("SIGTERM"); + }); +} else { + const baseName = "vibe-kanban"; + const binaryName = getBinaryName(baseName); + const binaryPath = path.join(extractDir, binaryName); + const zipName = `${baseName}.zip`; + const zipPath = path.join(extractDir, zipName); + + // Check if binary exists, delete if it does + if (fs.existsSync(binaryPath)) { + fs.unlinkSync(binaryPath); + } + + // Check if zip file exists + if (!fs.existsSync(zipPath)) { + console.error(`❌ ${zipName} not found at: ${zipPath}`); console.error(`Current platform: ${platform}-${arch} (${platformDir})`); process.exit(1); } - // Clean out any previous extraction (but keep the zip) - console.log("🧹 Cleaning up old files…"); - if (fs.existsSync(extractDir)) { - fs.readdirSync(extractDir).forEach((name) => { - if (name !== zipName) { - fs.rmSync(path.join(extractDir, name), { recursive: true, force: true }); - } - }); - } - // Unzip the file - console.log("πŸ“¦ Extracting vibe-kanban..."); + console.log(`πŸ“¦ Extracting ${baseName}...`); if (platform === "win32") { // Use PowerShell on Windows - execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force"`, { stdio: "inherit" }); + execSync( + `powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force"`, + { stdio: "inherit" } + ); } else { // Use unzip on Unix-like systems execSync(`unzip -o "${zipPath}" -d "${extractDir}"`, { stdio: "inherit" }); } - // Find the extracted directory (should match the zip structure) - const extractedDirs = fs.readdirSync(extractDir).filter(name => - name !== zipName && fs.statSync(path.join(extractDir, name)).isDirectory() - ); - - if (extractedDirs.length === 0) { - console.error("❌ No extracted directory found"); - process.exit(1); - } - - // Execute the binary - const binaryName = getBinaryName(); - const binaryPath = path.join(extractDir, extractedDirs[0], binaryName); - - if (!fs.existsSync(binaryPath)) { - console.error(`❌ Binary not found at: ${binaryPath}`); - process.exit(1); - } - - console.log(`πŸš€ Launching vibe-kanban (${platformDir})...`); - + console.log(`πŸš€ Launching ${baseName}...`); if (platform === "win32") { execSync(`"${binaryPath}"`, { stdio: "inherit" }); } else { @@ -96,7 +158,4 @@ try { execSync(`chmod +x "${binaryPath}"`); execSync(`"${binaryPath}"`, { stdio: "inherit" }); } -} catch (error) { - console.error("❌ Error running vibe-kanban:", error.message); - process.exit(1); } diff --git a/npx-cli/package.json b/npx-cli/package.json index b673a893..64bfdd19 100644 --- a/npx-cli/package.json +++ b/npx-cli/package.json @@ -1,16 +1,17 @@ { "name": "vibe-kanban", "private": false, - "version": "0.0.1", + "version": "0.0.24", "main": "index.js", "bin": { - "my-npx-cli": "bin/cli.js" + "vibe-kanban": "bin/cli.js" }, "keywords": [], "author": "bloop", "license": "", - "description": "NPX wrapper around vibe-kanban", + "description": "NPX wrapper around vibe-kanban and vibe-kanban-mcp", "files": [ - "dist" + "dist", + "bin" ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 656f13cd..fbea7e42 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,10 @@ "private": true, "scripts": { "dev": "concurrently \"cargo watch -w backend -x 'run --manifest-path backend/Cargo.toml'\" \"npm run frontend:dev\"", - "build": "npm run frontend:build && cargo build --release --manifest-path backend/Cargo.toml", + "build": "npm run frontend:build && cargo build --release --manifest-path backend/Cargo.toml && cargo build --release --bin mcp_task_server --manifest-path backend/Cargo.toml", "build:single": "npm run frontend:build && cargo build --release --manifest-path backend/Cargo.toml", + "build:npm": "./build-npm-package.sh", + "test:npm": "./test-npm-package.sh", "frontend:dev": "cd frontend && npm run dev", "frontend:build": "cd frontend && npm run build", "backend:dev": "cargo watch -w backend -x 'run --manifest-path backend/Cargo.toml'", diff --git a/test-npm-package.sh b/test-npm-package.sh new file mode 100755 index 00000000..96dd7636 --- /dev/null +++ b/test-npm-package.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# test-npm-package.sh + +set -e + +echo "πŸ§ͺ Testing NPM package locally..." + +# Build the package first +./build-npm-package.sh + +cd npx-cli + +echo "πŸ“‹ Checking files to be included..." +npm pack --dry-run + +echo "πŸ“¦ Creating package tarball..." +npm pack + +echo "πŸ”— Installing globally from tarball..." +TARBALL=$(ls vibe-kanban-*.tgz | head -n1) +npm install -g "./$TARBALL" + +echo "πŸ§ͺ Testing main command..." +vibe-kanban & +MAIN_PID=$! +sleep 3 +kill $MAIN_PID 2>/dev/null || true +wait $MAIN_PID 2>/dev/null || true +echo "βœ… Main app started successfully" + +echo "πŸ§ͺ Testing MCP command with complete handshake..." + +node ../mcp_test.js + +echo "🧹 Cleaning up..." +npm uninstall -g vibe-kanban +rm "$TARBALL" + +echo "βœ… NPM package test completed successfully!" +echo "" +echo "πŸŽ‰ Your MCP server is working correctly!" +echo "πŸ“‹ Next steps:" +echo " 1. cd npx-cli" +echo " 2. npm publish" +echo " 3. Users can then use: npx vibe-kanban --mcp with Claude Desktop" \ No newline at end of file