task-graph-mcp 0.5.0

MCP server for agent task workflows with phases, prompts, gates, and multi-agent coordination
Documentation
//! Agent feedback tools.
//!
//! Feedback is stored as a simple, human-readable, append-only markdown file.

use crate::config::FeedbackConfig;
use crate::db::Database;
use crate::error::ToolError;
use crate::tools::{get_string, make_tool};
use anyhow::Result;
use rmcp::model::Tool;
use serde_json::{Value, json};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;

/// Valid feedback categories.
const CATEGORIES: &[&str] = &["tool", "workflow", "config", "ux", "general"];

/// Valid feedback sentiments.
const SENTIMENTS: &[&str] = &["positive", "negative", "neutral", "suggestion"];

/// Feedback file name.
const FEEDBACK_FILE: &str = "feedback.md";

/// Get feedback tool definitions.
pub fn get_tools() -> Vec<Tool> {
    vec![
        make_tool(
            "give_feedback",
            "Submit feedback about tools, workflows, configuration, or UX. \
             Appends to a human-readable markdown file. Never shared automatically. \
             Rejects writes if the file exceeds the configured size limit (default: 1MB).",
            json!({
                "message": {
                    "type": "string",
                    "description": "The feedback message"
                },
                "category": {
                    "type": "string",
                    "enum": CATEGORIES,
                    "description": "Feedback category (default: general)",
                    "default": "general"
                },
                "sentiment": {
                    "type": "string",
                    "enum": SENTIMENTS,
                    "description": "Sentiment of the feedback (default: neutral)",
                    "default": "neutral"
                },
                "agent_id": {
                    "type": "string",
                    "description": "ID of the agent submitting feedback"
                },
                "tool_name": {
                    "type": "string",
                    "description": "Name of the tool this feedback is about"
                },
                "task_id": {
                    "type": "string",
                    "description": "ID of the task this feedback relates to"
                }
            }),
            vec!["message"],
        ),
        make_tool(
            "list_feedback",
            "Read the feedback markdown file. Returns the raw contents.",
            json!({}),
            vec![],
        ),
    ]
}

/// Resolve the feedback file path next to the database file.
fn feedback_path(db_dir: &Path) -> std::path::PathBuf {
    db_dir.join(FEEDBACK_FILE)
}

/// Handle the give_feedback tool call.
///
/// When `db` is provided and the caller supplies an `agent_id`, the worker's
/// workflow and overlays are looked up and recorded in the feedback entry.
pub fn give_feedback(
    db_dir: &Path,
    config: &FeedbackConfig,
    db: Option<&Database>,
    args: Value,
) -> Result<Value> {
    let message =
        get_string(&args, "message").ok_or_else(|| ToolError::missing_field("message"))?;

    if message.trim().is_empty() {
        return Err(ToolError::invalid_value("message", "message cannot be empty").into());
    }

    let category = get_string(&args, "category").unwrap_or_else(|| "general".to_string());
    if !CATEGORIES.contains(&category.as_str()) {
        return Err(ToolError::invalid_value(
            "category",
            &format!(
                "Invalid category '{}'. Must be one of: {}",
                category,
                CATEGORIES.join(", ")
            ),
        )
        .into());
    }

    let sentiment = get_string(&args, "sentiment").unwrap_or_else(|| "neutral".to_string());
    if !SENTIMENTS.contains(&sentiment.as_str()) {
        return Err(ToolError::invalid_value(
            "sentiment",
            &format!(
                "Invalid sentiment '{}'. Must be one of: {}",
                sentiment,
                SENTIMENTS.join(", ")
            ),
        )
        .into());
    }

    let agent_id = get_string(&args, "agent_id");
    let tool_name = get_string(&args, "tool_name");
    let task_id = get_string(&args, "task_id");

    let path = feedback_path(db_dir);

    // Check size limit before writing (0 = unlimited)
    if config.max_size_bytes > 0 {
        let current_size = path.metadata().map(|m| m.len()).unwrap_or(0);
        if current_size >= config.max_size_bytes {
            return Err(ToolError::invalid_value(
                "feedback",
                &format!(
                    "Feedback file has reached the size limit ({} bytes). No more feedback can be recorded.",
                    config.max_size_bytes
                ),
            )
            .into());
        }
    }

    // If file doesn't exist yet, write the header
    let needs_header = !path.exists();

    let mut file = OpenOptions::new().create(true).append(true).open(&path)?;

    if needs_header {
        writeln!(file, "# Agent Feedback\n")?;
    }

    // Build the entry
    let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
    writeln!(file, "---\n")?;
    writeln!(file, "### {} | {} | {}\n", timestamp, category, sentiment)?;

    // Optional metadata lines
    if let Some(ref agent) = agent_id {
        writeln!(file, "- **Agent:** {}", agent)?;
    }
    if let Some(ref tool) = tool_name {
        writeln!(file, "- **Tool:** {}", tool)?;
    }
    if let Some(ref task) = task_id {
        writeln!(file, "- **Task:** {}", task)?;
    }

    // Look up workflow/overlay metadata from the worker record
    let mut has_workflow_meta = false;
    if let (Some(agent), Some(db)) = (&agent_id, db)
        && let Ok(Some(worker)) = db.get_worker(agent)
    {
        if let Some(ref wf) = worker.workflow {
            writeln!(file, "- **Workflow:** {}", wf)?;
            has_workflow_meta = true;
        }
        if !worker.overlays.is_empty() {
            writeln!(file, "- **Overlays:** {}", worker.overlays.join(", "))?;
            has_workflow_meta = true;
        }
    }

    if agent_id.is_some() || tool_name.is_some() || task_id.is_some() || has_workflow_meta {
        writeln!(file)?;
    }

    writeln!(file, "{}\n", message)?;

    Ok(json!({
        "status": "recorded",
        "file": path.display().to_string()
    }))
}

/// Handle the list_feedback tool call.
pub fn list_feedback(db_dir: &Path) -> Result<Value> {
    let path = feedback_path(db_dir);

    if !path.exists() {
        return Ok(json!({
            "content": "",
            "file": path.display().to_string(),
            "message": "No feedback recorded yet."
        }));
    }

    let content = fs::read_to_string(&path)?;

    Ok(json!({
        "content": content,
        "file": path.display().to_string()
    }))
}