Skip to main content

task_graph_mcp/tools/
attachments.rs

1//! Attachment management tools.
2
3use super::{get_bool, get_string, make_tool_with_prompts};
4use crate::format::{format_attachments_markdown, markdown_to_json, OutputFormat};
5use crate::config::{AttachmentsConfig, Prompts, UnknownKeyBehavior};
6use crate::db::Database;
7use crate::error::{ErrorCode, ToolError};
8use anyhow::Result;
9use rmcp::model::Tool;
10use serde_json::{json, Value};
11use std::path::Path;
12
13pub fn get_tools(prompts: &Prompts) -> Vec<Tool> {
14    vec![
15        make_tool_with_prompts(
16            "attach",
17            "Add an attachment to a task. Use for notes, comments, or file references.\n\n\
18             For inline content: provide 'content' directly.\n\
19             For file reference: provide 'file' path (existing file, will be referenced).\n\
20             For media storage: provide 'content' + 'store_as_file'=true (saves to .task-graph/media/).",
21            json!({
22                "agent": {
23                    "type": "string",
24                    "description": "Agent ID"
25                },
26                "task": {
27                    "oneOf": [
28                        { "type": "string" },
29                        { "type": "array", "items": { "type": "string" } }
30                    ],
31                    "description": "Task ID or array of Task IDs for bulk attachment"
32                },
33                "name": {
34                    "type": "string",
35                    "description": "Attachment name (use 'meta' for structured metadata)"
36                },
37                "content": {
38                    "type": "string",
39                    "description": "Content (text or base64). Optional if 'file' is provided."
40                },
41                "mime": {
42                    "type": "string",
43                    "description": "MIME type (default: text/plain)"
44                },
45                "file": {
46                    "type": "string",
47                    "description": "Path to existing file to reference (alternative to content)"
48                },
49                "store_as_file": {
50                    "type": "boolean",
51                    "description": "If true, store content in .task-graph/media/ instead of database"
52                },
53                "mode": {
54                    "type": "string",
55                    "enum": ["append", "replace"],
56                    "description": "How to handle existing attachment with same name: 'append' (default) keeps both, 'replace' deletes old"
57                }
58            }),
59            vec!["task", "name"],
60            prompts,
61        ),
62        make_tool_with_prompts(
63            "attachments",
64            "Get attachments for a task. Returns metadata only.\n\n\
65             To retrieve attachment content, use the `get_attachment` API (not yet available via MCP).",
66            json!({
67                "task": {
68                    "type": "string",
69                    "description": "Task ID"
70                },
71                "name": {
72                    "type": "string",
73                    "description": "Filter by attachment name pattern (glob syntax: * matches any chars)"
74                },
75                "mime": {
76                    "type": "string",
77                    "description": "Filter by MIME type prefix (e.g., 'image/' matches image/png, image/jpeg)"
78                }
79            }),
80            vec!["task"],
81            prompts,
82        ),
83        make_tool_with_prompts(
84            "detach",
85            "Delete an attachment by task and name.",
86            json!({
87                "agent": {
88                    "type": "string",
89                    "description": "Agent ID"
90                },
91                "task": {
92                    "type": "string",
93                    "description": "Task ID"
94                },
95                "name": {
96                    "type": "string",
97                    "description": "Attachment name to delete"
98                },
99                "delete_file": {
100                    "type": "boolean",
101                    "description": "If true, also delete the file from .task-graph/media/ (default: false)"
102                }
103            }),
104            vec!["agent", "task", "name"],
105            prompts,
106        ),
107    ]
108}
109
110/// Generate a unique filename for media storage.
111fn generate_media_filename(task_id: &str, name: &str, mime_type: &str) -> String {
112    let timestamp = std::time::SystemTime::now()
113        .duration_since(std::time::UNIX_EPOCH)
114        .map(|d| d.as_millis())
115        .unwrap_or(0);
116
117    // Determine extension from mime type
118    let ext = match mime_type {
119        "application/json" => "json",
120        "text/plain" => "txt",
121        "text/markdown" => "md",
122        "text/html" => "html",
123        "image/png" => "png",
124        "image/jpeg" => "jpg",
125        "image/gif" => "gif",
126        "image/webp" => "webp",
127        "application/pdf" => "pdf",
128        _ => "bin",
129    };
130
131    // Sanitize name for filename
132    let safe_name: String = name
133        .chars()
134        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
135        .collect();
136
137    format!("{}_{}_{}.{}", task_id, safe_name, timestamp, ext)
138}
139
140/// Check if a file path is within the media directory.
141fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
142    let file_path = Path::new(file_path);
143
144    // Try to canonicalize both paths for comparison
145    if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
146        file_abs.starts_with(media_abs)
147    } else {
148        // Fall back to string prefix check
149        file_path.starts_with(media_dir)
150    }
151}
152
153pub fn attach(db: &Database, media_dir: &Path, attachments_config: &AttachmentsConfig, args: Value) -> Result<Value> {
154    // Agent parameter is optional - for tracking/audit purposes
155    let _agent_id = get_string(&args, "agent");
156
157    // Task can be string or array of strings
158    let task_ids: Vec<String> = if let Some(task_array) = args.get("task").and_then(|v| v.as_array()) {
159        task_array
160            .iter()
161            .filter_map(|v| v.as_str().map(String::from))
162            .collect()
163    } else if let Some(task_id) = get_string(&args, "task") {
164        vec![task_id]
165    } else {
166        return Err(ToolError::missing_field("task").into());
167    };
168
169    if task_ids.is_empty() {
170        return Err(ToolError::new(ErrorCode::InvalidFieldValue, "At least one task ID must be provided").into());
171    }
172
173    let name = get_string(&args, "name")
174        .ok_or_else(|| ToolError::missing_field("name"))?;
175    let content = get_string(&args, "content");
176    let file_path = get_string(&args, "file");
177    let store_as_file = get_bool(&args, "store_as_file").unwrap_or(false);
178
179    // Check if this is a known key and handle unknown_key behavior
180    let is_known = attachments_config.is_known_key(&name);
181    let warning: Option<String> = if !is_known {
182        match attachments_config.unknown_key {
183            UnknownKeyBehavior::Reject => {
184                return Err(ToolError::new(
185                    ErrorCode::InvalidFieldValue,
186                    format!("Unknown attachment key '{}'. Configure it in attachments.definitions or set unknown_key to 'allow' or 'warn'.", name)
187                ).into());
188            }
189            UnknownKeyBehavior::Warn => {
190                Some(format!("Unknown attachment key '{}'", name))
191            }
192            UnknownKeyBehavior::Allow => None,
193        }
194    } else {
195        None
196    };
197
198    // Use config defaults for mime/mode, but allow explicit overrides from args
199    let mime_type = get_string(&args, "mime")
200        .unwrap_or_else(|| attachments_config.get_mime_default(&name).to_string());
201    let mode = get_string(&args, "mode")
202        .unwrap_or_else(|| attachments_config.get_mode_default(&name).to_string());
203
204    // Validate mode
205    if mode != "append" && mode != "replace" {
206        return Err(ToolError::new(ErrorCode::InvalidFieldValue, "mode must be 'append' or 'replace'").into());
207    }
208
209    // Validate: need either content or file
210    if content.is_none() && file_path.is_none() {
211        return Err(ToolError::new(ErrorCode::InvalidFieldValue, "Either 'content' or 'file' must be provided").into());
212    }
213
214    // Handle different attachment modes - prepare content/file once for all tasks
215    let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
216        // File reference mode: verify file exists
217        let path = Path::new(fp);
218        if !path.exists() {
219            return Err(ToolError::new(ErrorCode::FileNotFound, format!("File not found: {}", fp)).into());
220        }
221        (String::new(), Some(fp.clone()))
222    } else if store_as_file {
223        // For store_as_file with multiple tasks, we'll create per-task files
224        (content.clone().unwrap(), None)
225    } else {
226        // Inline content mode
227        (content.unwrap(), None)
228    };
229
230    let mut results = Vec::new();
231
232    for task_id in &task_ids {
233        // Replace mode: delete existing attachment with same name before adding new one
234        if mode == "replace" {
235            if let Ok(Some(old_file_path)) = db.delete_attachment_by_name(task_id, &name) {
236                // Clean up old media file if it was in media dir
237                if is_in_media_dir(&old_file_path, media_dir) {
238                    let _ = std::fs::remove_file(&old_file_path);
239                }
240            }
241        }
242
243        // Determine final content and file path for this task
244        let (final_content, final_file_path): (String, Option<String>) = if store_as_file && file_path.is_none() {
245            // Store content to media directory (per-task file)
246            let filename = generate_media_filename(task_id, &name, &mime_type);
247            let media_file_path = media_dir.join(&filename);
248
249            // Ensure media directory exists
250            std::fs::create_dir_all(media_dir)?;
251
252            // Write content to file
253            std::fs::write(&media_file_path, &base_content)?;
254
255            let file_path_str = media_file_path.to_string_lossy().to_string();
256            (String::new(), Some(file_path_str))
257        } else {
258            (base_content.clone(), base_file_path.clone())
259        };
260
261        let order_index = db.add_attachment(task_id, name.clone(), final_content, Some(mime_type.clone()), final_file_path.clone())?;
262
263        let mut result = json!({
264            "task_id": task_id,
265            "order_index": order_index
266        });
267
268        if let Some(fp) = final_file_path {
269            result["file_path"] = json!(fp);
270        }
271
272        results.push(result);
273    }
274
275    // Return single result for single task, array for bulk
276    let mut response = if results.len() == 1 {
277        results.into_iter().next().unwrap()
278    } else {
279        json!({ "attachments": results })
280    };
281
282    // Add warning if unknown key behavior is "warn"
283    if let Some(warn_msg) = warning {
284        response["warning"] = json!(warn_msg);
285    }
286
287    Ok(response)
288}
289
290pub fn attachments(db: &Database, _media_dir: &Path, default_format: OutputFormat, args: Value) -> Result<Value> {
291    let task_id = get_string(&args, "task")
292        .ok_or_else(|| ToolError::missing_field("task"))?;
293    let name_pattern = get_string(&args, "name");
294    let mime_pattern = get_string(&args, "mime");
295    let format = get_string(&args, "format")
296        .and_then(|s| OutputFormat::from_str(&s))
297        .unwrap_or(default_format);
298
299    // Get filtered attachments (metadata only)
300    let attachments = db.get_attachments_filtered(
301        &task_id,
302        name_pattern.as_deref(),
303        mime_pattern.as_deref(),
304    )?;
305
306    match format {
307        OutputFormat::Markdown => Ok(markdown_to_json(format_attachments_markdown(&attachments))),
308        OutputFormat::Json => {
309            let results: Vec<Value> = attachments
310                .iter()
311                .map(|a| {
312                    let mut obj = json!({
313                        "task_id": &a.task_id,
314                        "order_index": a.order_index,
315                        "name": a.name,
316                        "mime_type": a.mime_type,
317                        "created_at": a.created_at
318                    });
319
320                    if let Some(ref fp) = a.file_path {
321                        obj["file_path"] = json!(fp);
322                    }
323
324                    obj
325                })
326                .collect();
327
328            Ok(json!({ "attachments": results }))
329        }
330    }
331}
332
333pub fn detach(db: &Database, media_dir: &Path, args: Value) -> Result<Value> {
334    // Agent parameter is optional - for tracking/audit purposes
335    let _agent_id = get_string(&args, "agent");
336    
337    let task_id = get_string(&args, "task")
338        .ok_or_else(|| ToolError::missing_field("task"))?;
339    let name = get_string(&args, "name")
340        .ok_or_else(|| ToolError::missing_field("name"))?;
341    let delete_file = get_bool(&args, "delete_file").unwrap_or(false);
342
343    // Delete from database (returns whether deleted and file_path if one was set)
344    let (deleted, file_path) = db.delete_attachment_by_name_ex(&task_id, &name)?;
345
346    // If attachment had a file in media dir and delete_file is true, delete it
347    let mut file_deleted = false;
348    if delete_file {
349        if let Some(fp) = &file_path {
350            if is_in_media_dir(fp, media_dir) {
351                let path = Path::new(fp);
352                if path.exists() {
353                    if let Ok(()) = std::fs::remove_file(path) {
354                        file_deleted = true;
355                    }
356                }
357            }
358        }
359    }
360
361    Ok(json!({
362        "success": deleted,
363        "file_deleted": file_deleted
364    }))
365}