task_graph_mcp/tools/
feedback.rs1use crate::error::ToolError;
6use crate::tools::{get_string, make_tool};
7use anyhow::Result;
8use rmcp::model::Tool;
9use serde_json::{Value, json};
10use std::fs::{self, OpenOptions};
11use std::io::Write;
12use std::path::Path;
13
14const CATEGORIES: &[&str] = &["tool", "workflow", "config", "ux", "general"];
16
17const SENTIMENTS: &[&str] = &["positive", "negative", "neutral", "suggestion"];
19
20const FEEDBACK_FILE: &str = "feedback.md";
22
23pub fn get_tools() -> Vec<Tool> {
25 vec![
26 make_tool(
27 "give_feedback",
28 "Submit feedback about tools, workflows, configuration, or UX. \
29 Appends to a human-readable markdown file. Never shared automatically.",
30 json!({
31 "message": {
32 "type": "string",
33 "description": "The feedback message"
34 },
35 "category": {
36 "type": "string",
37 "enum": CATEGORIES,
38 "description": "Feedback category (default: general)",
39 "default": "general"
40 },
41 "sentiment": {
42 "type": "string",
43 "enum": SENTIMENTS,
44 "description": "Sentiment of the feedback (default: neutral)",
45 "default": "neutral"
46 },
47 "agent_id": {
48 "type": "string",
49 "description": "ID of the agent submitting feedback"
50 },
51 "tool_name": {
52 "type": "string",
53 "description": "Name of the tool this feedback is about"
54 },
55 "task_id": {
56 "type": "string",
57 "description": "ID of the task this feedback relates to"
58 }
59 }),
60 vec!["message"],
61 ),
62 make_tool(
63 "list_feedback",
64 "Read the feedback markdown file. Returns the raw contents.",
65 json!({}),
66 vec![],
67 ),
68 ]
69}
70
71fn feedback_path(db_dir: &Path) -> std::path::PathBuf {
73 db_dir.join(FEEDBACK_FILE)
74}
75
76pub fn give_feedback(db_dir: &Path, args: Value) -> Result<Value> {
78 let message =
79 get_string(&args, "message").ok_or_else(|| ToolError::missing_field("message"))?;
80
81 if message.trim().is_empty() {
82 return Err(ToolError::invalid_value("message", "message cannot be empty").into());
83 }
84
85 let category = get_string(&args, "category").unwrap_or_else(|| "general".to_string());
86 if !CATEGORIES.contains(&category.as_str()) {
87 return Err(ToolError::invalid_value(
88 "category",
89 &format!(
90 "Invalid category '{}'. Must be one of: {}",
91 category,
92 CATEGORIES.join(", ")
93 ),
94 )
95 .into());
96 }
97
98 let sentiment = get_string(&args, "sentiment").unwrap_or_else(|| "neutral".to_string());
99 if !SENTIMENTS.contains(&sentiment.as_str()) {
100 return Err(ToolError::invalid_value(
101 "sentiment",
102 &format!(
103 "Invalid sentiment '{}'. Must be one of: {}",
104 sentiment,
105 SENTIMENTS.join(", ")
106 ),
107 )
108 .into());
109 }
110
111 let agent_id = get_string(&args, "agent_id");
112 let tool_name = get_string(&args, "tool_name");
113 let task_id = get_string(&args, "task_id");
114
115 let path = feedback_path(db_dir);
116
117 let needs_header = !path.exists();
119
120 let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
121
122 if needs_header {
123 writeln!(file, "# Agent Feedback\n")?;
124 }
125
126 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
128 writeln!(file, "---\n")?;
129 writeln!(file, "### {} | {} | {}\n", timestamp, category, sentiment)?;
130
131 if let Some(ref agent) = agent_id {
133 writeln!(file, "- **Agent:** {}", agent)?;
134 }
135 if let Some(ref tool) = tool_name {
136 writeln!(file, "- **Tool:** {}", tool)?;
137 }
138 if let Some(ref task) = task_id {
139 writeln!(file, "- **Task:** {}", task)?;
140 }
141 if agent_id.is_some() || tool_name.is_some() || task_id.is_some() {
142 writeln!(file)?;
143 }
144
145 writeln!(file, "{}\n", message)?;
146
147 Ok(json!({
148 "status": "recorded",
149 "file": path.display().to_string()
150 }))
151}
152
153pub fn list_feedback(db_dir: &Path) -> Result<Value> {
155 let path = feedback_path(db_dir);
156
157 if !path.exists() {
158 return Ok(json!({
159 "content": "",
160 "file": path.display().to_string(),
161 "message": "No feedback recorded yet."
162 }));
163 }
164
165 let content = fs::read_to_string(&path)?;
166
167 Ok(json!({
168 "content": content,
169 "file": path.display().to_string()
170 }))
171}