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