feat: easy vibe_kanban MCP config (#26)

* inject project_id into prompt; remove create_project tool

* path hack for vibe-kanban mcp button

* update mcp name

* update mcp configuration

* merge stderr into stdout for mcp process

* add -y to mcp server config

* fmt

* fmt

* revert reversion of cli.js

* rename mcp server to vibe-kanban

* improve tool descriptions
This commit is contained in:
Gabriel Gordon-Hall
2025-07-07 10:39:12 +01:00
committed by GitHub
parent 17ed214c62
commit 8037946500
9 changed files with 105 additions and 563 deletions

1
.gitignore vendored
View File

@@ -81,3 +81,4 @@ backend/db.sqlite
dev_assets
/frontend/.env.sentry-build-plugin
.ssh

View File

@@ -38,7 +38,12 @@ impl Executor for AmpExecutor {
use tokio::{io::AsyncWriteExt, process::Command};
let prompt = format!(
"Task title: {}\nTask description: {}",
r#"project_id: {}
Task title: {}
Task description: {}
"#,
task.project_id,
task.title,
task.description
.as_deref()

View File

@@ -35,8 +35,12 @@ impl Executor for ClaudeExecutor {
.ok_or(ExecutorError::TaskNotFound)?;
let prompt = format!(
"Task title: {}
Task description: {}",
r#"project_id: {}
Task title: {}
Task description: {}
"#,
task.project_id,
task.title,
task.description
.as_deref()

View File

@@ -34,7 +34,12 @@ impl Executor for GeminiExecutor {
.ok_or(ExecutorError::TaskNotFound)?;
let prompt = format!(
"Task title: {}\nTask description: {}",
r#"project_id: {}
Task title: {}
Task description: {}
"#,
task.project_id,
task.title,
task.description
.as_deref()

View File

@@ -35,7 +35,12 @@ impl Executor for OpencodeExecutor {
use tokio::process::Command;
let prompt = format!(
"Task title: {}\nTask description: {}",
r#"project_id: {}
Task title: {}
Task description: {}
"#,
task.project_id,
task.title,
task.description
.as_deref()

View File

@@ -10,13 +10,13 @@ use sqlx::SqlitePool;
use uuid::Uuid;
use crate::models::{
project::{CreateProject, Project},
project::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")]
#[schemars(description = "The ID of the project to create the task in. This is required!")]
pub project_id: String,
#[schemars(description = "The title of the task")]
pub title: String,
@@ -170,54 +170,6 @@ pub struct DeleteTaskResponse {
pub deleted_task_id: Option<String>,
}
#[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,
@@ -241,28 +193,6 @@ pub struct GetTaskResponse {
pub project_name: Option<String>,
}
#[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<bool>,
#[schemars(description = "Optional setup script command")]
pub setup_script: Option<String>,
#[schemars(description = "Optional development script command")]
pub dev_script: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct CreateProjectResponse {
pub success: bool,
pub message: String,
pub project_id: Option<String>,
pub project: Option<ProjectSummary>,
}
#[derive(Debug, Clone)]
pub struct TaskServer {
pub pool: SqlitePool,
@@ -277,7 +207,9 @@ impl TaskServer {
#[tool(tool_box)]
impl TaskServer {
#[tool(description = "Create a new task in a project")]
#[tool(
description = "Create a new task/ticket in a project. Always pass the `project_id` of the project you want to create the task in - it is required!"
)]
async fn create_task(
&self,
#[tool(aggr)] CreateTaskRequest {
@@ -365,7 +297,7 @@ impl TaskServer {
}
}
#[tool(description = "List all available projects")]
#[tool(description = "List all the available projects")]
async fn list_projects(
&self,
#[tool(aggr)] _request: ListProjectsRequest,
@@ -415,7 +347,9 @@ impl TaskServer {
}
}
#[tool(description = "List tasks in a project with optional filtering and execution status")]
#[tool(
description = "List all the task/tickets in a project with optional filtering and execution status. `project_id` is required!"
)]
async fn list_tasks(
&self,
#[tool(aggr)] ListTasksRequest {
@@ -552,7 +486,9 @@ impl TaskServer {
}
}
#[tool(description = "Update an existing task's title, description, or status")]
#[tool(
description = "Update an existing task/ticket's title, description, or status. `project_id` and `task_id` are required! `title`, `description`, and `status` are optional."
)]
async fn update_task(
&self,
#[tool(aggr)] UpdateTaskRequest {
@@ -685,405 +621,9 @@ impl TaskServer {
}
}
#[tool(description = "Mark a task as completed by its title")]
async fn complete_task(
&self,
#[tool(aggr)] CompleteTaskRequest {
project_id,
task_title,
}: CompleteTaskRequest,
) -> Result<CallToolResult, RmcpError> {
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<CallToolResult, RmcpError> {
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<CallToolResult, RmcpError> {
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<CallToolResult, RmcpError> {
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, &current_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<CallToolResult, RmcpError> {
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")]
#[tool(
description = "Delete a task/ticket from a project. `project_id` and `task_id` are required!"
)]
async fn delete_task(
&self,
#[tool(aggr)] DeleteTaskRequest {
@@ -1175,7 +715,9 @@ impl TaskServer {
}
}
#[tool(description = "Get detailed information about a specific task")]
#[tool(
description = "Get detailed information about a specific task/ticket. `project_id` and `task_id` are required!"
)]
async fn get_task(
&self,
#[tool(aggr)] GetTaskRequest {
@@ -1258,66 +800,6 @@ impl TaskServer {
}
}
}
#[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<CallToolResult, RmcpError> {
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)]
@@ -1329,10 +811,10 @@ impl ServerHandler for TaskServer {
.enable_tools()
.build(),
server_info: Implementation {
name: "task-manager".to_string(),
name: "vibe-kanban".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()),
instructions: Some("A task and project management server. If you need to create or update tickets or tasks then use these tools. Most of them absolutely require that you pass the `project_id` of the project that you are currently working on. This should be provided to you. TOOLS: 'list_projects', 'list_tasks', 'create_task', 'get_task', 'update_task', 'delete_task'. Make sure to pass `project_id` or `task_id` where required. You can use list tools to get the available ids.".to_string()),
}
}
}

View File

@@ -312,22 +312,4 @@ impl Task {
.await?;
Ok(result.is_some())
}
pub async fn find_task_by_title(
pool: &SqlitePool,
project_id: Uuid,
title: &str,
) -> Result<Option<Self>, 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<Utc>", updated_at as "updated_at!: DateTime<Utc>"
FROM tasks
WHERE project_id = $1 AND title = $2
LIMIT 1"#,
project_id,
title
)
.fetch_optional(pool)
.await
}
}

View File

@@ -134,6 +134,49 @@ export function McpServers() {
}
};
const handleConfigureVibeKanban = async () => {
if (!selectedMcpExecutor) return;
try {
// Parse existing configuration
const existingConfig = mcpServers.trim() ? JSON.parse(mcpServers) : {};
// Always use production MCP installation instructions
const vibeKanbanConfig = {
command: 'npx',
args: ['-y', 'vibe-kanban', '--mcp'],
};
// Add vibe_kanban to the existing configuration
let updatedConfig;
if (selectedMcpExecutor === 'amp') {
updatedConfig = {
...existingConfig,
'amp.mcpServers': {
...(existingConfig['amp.mcpServers'] || {}),
vibe_kanban: vibeKanbanConfig,
},
};
} else {
updatedConfig = {
...existingConfig,
mcpServers: {
...(existingConfig.mcpServers || {}),
vibe_kanban: vibeKanbanConfig,
},
};
}
// Update the textarea with the new configuration
const configJson = JSON.stringify(updatedConfig, null, 2);
setMcpServers(configJson);
setMcpError(null);
} catch (err) {
setMcpError('Failed to configure vibe-kanban MCP server');
console.error('Error configuring vibe-kanban:', err);
}
};
const handleApplyMcpServers = async () => {
if (!selectedMcpExecutor) return;
@@ -329,6 +372,19 @@ export function McpServers() {
</span>
)}
</div>
<div className="pt-4">
<Button
onClick={handleConfigureVibeKanban}
disabled={mcpApplying || mcpLoading || !selectedMcpExecutor}
className="w-64"
>
Add Vibe-Kanban MCP
</Button>
<p className="text-sm text-muted-foreground mt-2">
Automatically adds the Vibe-Kanban MCP server.
</p>
</div>
</div>
)}
</CardContent>

View File

@@ -92,16 +92,18 @@ function extractAndRun(baseName, launch) {
if (platform !== "win32") {
try {
fs.chmodSync(binPath, 0o755);
} catch {}
} catch { }
}
return launch(binPath);
}
if (isMcpMode) {
extractAndRun("vibe-kanban-mcp", (bin) => {
const proc = spawn(bin, [], { stdio: ["pipe", "pipe", "inherit"] });
const proc = spawn(bin, [], { stdio: ["pipe", "pipe", "pipe"] });
process.stdin.pipe(proc.stdin);
proc.stdout.pipe(process.stdout);
proc.stderr.pipe(process.stdout);
proc.on("exit", (c) => process.exit(c || 0));
proc.on("error", (e) => {
console.error("❌ MCP server error:", e.message);