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 <abcpro11051@disroot.org>

* Update crates/server/src/mcp/task_server.rs

Co-authored-by: Solomon <abcpro11051@disroot.org>

---------

Co-authored-by: Solomon <abcpro11051@disroot.org>
This commit is contained in:
Louis Knight-Webb
2025-12-09 10:37:46 +00:00
committed by GitHub
parent 21d175bccc
commit e83e0ee169
3 changed files with 70 additions and 2 deletions

1
Cargo.lock generated
View File

@@ -4642,6 +4642,7 @@ dependencies = [
"openssl-sys", "openssl-sys",
"os_info", "os_info",
"rand 0.8.5", "rand 0.8.5",
"regex",
"remote", "remote",
"reqwest", "reqwest",
"rmcp", "rmcp",

View File

@@ -47,6 +47,7 @@ url = "2.5"
rand = { version = "0.8", features = ["std"] } rand = { version = "0.8", features = ["std"] }
sha2 = "0.10" sha2 = "0.10"
strum = "0.27.2" strum = "0.27.2"
regex = "1"
[build-dependencies] [build-dependencies]
dotenv = "0.15" dotenv = "0.15"

View File

@@ -2,10 +2,12 @@ use std::{future::Future, path::PathBuf, str::FromStr};
use db::models::{ use db::models::{
project::Project, project::Project,
tag::Tag,
task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask}, task::{CreateTask, Task, TaskStatus, TaskWithAttemptStatus, UpdateTask},
task_attempt::{TaskAttempt, TaskAttemptContext}, task_attempt::{TaskAttempt, TaskAttemptContext},
}; };
use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId}; use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId};
use regex::Regex;
use rmcp::{ use rmcp::{
ErrorData, ServerHandler, ErrorData, ServerHandler,
handler::server::tool::{Parameters, ToolRouter}, handler::server::tool::{Parameters, ToolRouter},
@@ -385,6 +387,58 @@ impl TaskServer {
path.trim_start_matches('/') 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<String> = tag_pattern
.captures_iter(text)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect::<std::collections::HashSet<_>>()
.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<Tag> = match self.client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<ApiResponseEnvelope<Vec<Tag>>>().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: &regex::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] #[tool_router]
@@ -409,6 +463,12 @@ impl TaskServer {
description, description,
}): Parameters<CreateTaskRequest>, }): Parameters<CreateTaskRequest>,
) -> Result<CallToolResult, ErrorData> { ) -> Result<CallToolResult, ErrorData> {
// 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 url = self.url("/api/tasks");
let task: Task = match self let task: Task = match self
.send_json( .send_json(
@@ -417,7 +477,7 @@ impl TaskServer {
.json(&CreateTask::from_title_description( .json(&CreateTask::from_title_description(
project_id, project_id,
title, title,
description, expanded_description,
)), )),
) )
.await .await
@@ -604,9 +664,15 @@ impl TaskServer {
None None
}; };
// Expand @tagname references in description
let expanded_description = match description {
Some(desc) => Some(self.expand_tags(&desc).await),
None => None,
};
let payload = UpdateTask { let payload = UpdateTask {
title, title,
description, description: expanded_description,
status, status,
parent_task_attempt: None, parent_task_attempt: None,
image_ids: None, image_ids: None,