From e83e0ee1695d718761b7a7a91b24ab0f632585ca Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 9 Dec 2025 10:37:46 +0000 Subject: [PATCH] MCP server expand tag (vibe-kanban) (#1458) * Implementation is complete. Let me provide a summary of the changes made: ## Summary I've implemented tag expansion for the MCP server. Here are the changes: ### Files Modified 1. **`crates/server/Cargo.toml`** - Added `regex = "1"` dependency 2. **`crates/server/src/mcp/task_server.rs`** - Three changes: - Added imports for `Tag` and `Regex` - Added `expand_tags()` helper function that: - Finds all `@tagname` patterns using regex `@([^\s@]+)` - Fetches tags from `/api/tags` endpoint - Replaces each `@tagname` with the tag's content - Returns original text if tag not found or on error - Updated `create_task` to expand tags in description before creating - Updated `update_task` to expand tags in description before updating - Updated tool descriptions to mention tag expansion support ### How It Works When an AI agent calls `create_task` or `update_task` with a description containing `@tagname` (e.g., `"Please implement this feature @requirements"`), the MCP server will: 1. Parse the description for `@tagname` patterns 2. Fetch all available tags from the backend API 3. Replace each `@tagname` with the corresponding tag's `content` 4. Save the task with the expanded description If a tag is not found, the `@tagname` is left as-is. If there's a network error, the original text is preserved to ensure task creation doesn't fail. * Update crates/server/src/mcp/task_server.rs Co-authored-by: Solomon * Update crates/server/src/mcp/task_server.rs Co-authored-by: Solomon --------- Co-authored-by: Solomon --- Cargo.lock | 1 + crates/server/Cargo.toml | 1 + crates/server/src/mcp/task_server.rs | 70 +++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b556847a..d87f9972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4642,6 +4642,7 @@ dependencies = [ "openssl-sys", "os_info", "rand 0.8.5", + "regex", "remote", "reqwest", "rmcp", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 8b06e81d..3705a3c0 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -47,6 +47,7 @@ url = "2.5" rand = { version = "0.8", features = ["std"] } sha2 = "0.10" strum = "0.27.2" +regex = "1" [build-dependencies] dotenv = "0.15" diff --git a/crates/server/src/mcp/task_server.rs b/crates/server/src/mcp/task_server.rs index 4d791346..61ba8fad 100644 --- a/crates/server/src/mcp/task_server.rs +++ b/crates/server/src/mcp/task_server.rs @@ -2,10 +2,12 @@ use std::{future::Future, path::PathBuf, str::FromStr}; use db::models::{ project::Project, + tag::Tag, task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask}, task_attempt::{TaskAttempt, TaskAttemptContext}, }; use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId}; +use regex::Regex; use rmcp::{ ErrorData, ServerHandler, handler::server::tool::{Parameters, ToolRouter}, @@ -385,6 +387,58 @@ impl TaskServer { path.trim_start_matches('/') ) } + + /// Expands @tagname references in text by replacing them with tag content. + /// Returns the original text if expansion fails (e.g., network error). + /// Unknown tags are left as-is (not expanded, not an error). + async fn expand_tags(&self, text: &str) -> String { + // Pattern matches @tagname where tagname is non-whitespace, non-@ characters + let tag_pattern = match Regex::new(r"@([^\s@]+)") { + Ok(re) => re, + Err(_) => return text.to_string(), + }; + + // Find all unique tag names referenced in the text + let tag_names: Vec = tag_pattern + .captures_iter(text) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) + .collect::>() + .into_iter() + .collect(); + + if tag_names.is_empty() { + return text.to_string(); + } + + // Fetch all tags from the API + let url = self.url("/api/tags"); + let tags: Vec = match self.client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::>>().await { + Ok(envelope) if envelope.success => envelope.data.unwrap_or_default(), + _ => return text.to_string(), + } + } + _ => return text.to_string(), + }; + + // Build a map of tag_name -> content for quick lookup + let tag_map: std::collections::HashMap<&str, &str> = tags + .iter() + .map(|t| (t.tag_name.as_str(), t.content.as_str())) + .collect(); + + // Replace each @tagname with its content (if found) + let result = tag_pattern.replace_all(text, |caps: ®ex::Captures| { + let tag_name = caps.get(1).map(|m| m.as_str()).unwrap_or(""); + match tag_map.get(tag_name) { + Some(content) => (*content).to_string(), + None => caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string(), + } + }); + + result.into_owned() + } } #[tool_router] @@ -409,6 +463,12 @@ impl TaskServer { description, }): Parameters, ) -> Result { + // Expand @tagname references in description + let expanded_description = match description { + Some(desc) => Some(self.expand_tags(&desc).await), + None => None, + }; + let url = self.url("/api/tasks"); let task: Task = match self .send_json( @@ -417,7 +477,7 @@ impl TaskServer { .json(&CreateTask::from_title_description( project_id, title, - description, + expanded_description, )), ) .await @@ -604,9 +664,15 @@ impl TaskServer { None }; + // Expand @tagname references in description + let expanded_description = match description { + Some(desc) => Some(self.expand_tags(&desc).await), + None => None, + }; + let payload = UpdateTask { title, - description, + description: expanded_description, status, parent_task_attempt: None, image_ids: None,