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             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| {
135            if c.is_alphanumeric() || c == '-' || c == '_' {
136                c
137            } else {
138                '_'
139            }
140        })
141        .collect();
142
143    format!("{}_{}_{}.{}", task_id, safe_name, timestamp, ext)
144}
145
146/// Check if a file path is within the media directory.
147fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
148    let file_path = Path::new(file_path);
149
150    // Try to canonicalize both paths for comparison
151    if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
152        file_abs.starts_with(media_abs)
153    } else {
154        // Fall back to string prefix check
155        file_path.starts_with(media_dir)
156    }
157}
158
159pub fn attach(
160    db: &Database,
161    media_dir: &Path,
162    attachments_config: &AttachmentsConfig,
163    args: Value,
164) -> Result<Value> {
165    // Agent parameter is optional - for tracking/audit purposes
166    let _agent_id = get_string(&args, "agent");
167
168    // Task can be string or array of strings
169    let task_ids: Vec<String> =
170        if let Some(task_array) = args.get("task").and_then(|v| v.as_array()) {
171            task_array
172                .iter()
173                .filter_map(|v| v.as_str().map(String::from))
174                .collect()
175        } else if let Some(task_id) = get_string(&args, "task") {
176            vec![task_id]
177        } else {
178            return Err(ToolError::missing_field("task").into());
179        };
180
181    if task_ids.is_empty() {
182        return Err(ToolError::new(
183            ErrorCode::InvalidFieldValue,
184            "At least one task ID must be provided",
185        )
186        .into());
187    }
188
189    let name = get_string(&args, "name").ok_or_else(|| ToolError::missing_field("name"))?;
190    let content = get_string(&args, "content");
191    let file_path = get_string(&args, "file");
192    let store_as_file = get_bool(&args, "store_as_file").unwrap_or(false);
193
194    // Check if this is a known key and handle unknown_key behavior
195    let is_known = attachments_config.is_known_key(&name);
196    let warning: Option<String> = if !is_known {
197        match attachments_config.unknown_key {
198            UnknownKeyBehavior::Reject => {
199                return Err(ToolError::new(
200                    ErrorCode::InvalidFieldValue,
201                    format!("Unknown attachment key '{}'. Configure it in attachments.definitions or set unknown_key to 'allow' or 'warn'.", name)
202                ).into());
203            }
204            UnknownKeyBehavior::Warn => Some(format!("Unknown attachment key '{}'", name)),
205            UnknownKeyBehavior::Allow => None,
206        }
207    } else {
208        None
209    };
210
211    // Use config defaults for mime/mode, but allow explicit overrides from args
212    let mime_type = get_string(&args, "mime")
213        .unwrap_or_else(|| attachments_config.get_mime_default(&name).to_string());
214    let mode = get_string(&args, "mode")
215        .unwrap_or_else(|| attachments_config.get_mode_default(&name).to_string());
216
217    // Validate mode
218    if mode != "append" && mode != "replace" {
219        return Err(ToolError::new(
220            ErrorCode::InvalidFieldValue,
221            "mode must be 'append' or 'replace'",
222        )
223        .into());
224    }
225
226    // Validate: need either content or file
227    if content.is_none() && file_path.is_none() {
228        return Err(ToolError::new(
229            ErrorCode::InvalidFieldValue,
230            "Either 'content' or 'file' must be provided",
231        )
232        .into());
233    }
234
235    // Handle different attachment modes - prepare content/file once for all tasks
236    let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
237        // File reference mode: verify file exists
238        let path = Path::new(fp);
239        if !path.exists() {
240            return Err(
241                ToolError::new(ErrorCode::FileNotFound, format!("File not found: {}", fp)).into(),
242            );
243        }
244        (String::new(), Some(fp.clone()))
245    } else if store_as_file {
246        // For store_as_file with multiple tasks, we'll create per-task files
247        (content.clone().unwrap(), None)
248    } else {
249        // Inline content mode
250        (content.unwrap(), None)
251    };
252
253    let mut results = Vec::new();
254
255    for task_id in &task_ids {
256        // Replace mode: delete existing attachment with same name before adding new one
257        if mode == "replace"
258            && let Ok(Some(old_file_path)) = db.delete_attachment_by_name(task_id, &name) {
259                // Clean up old media file if it was in media dir
260                if is_in_media_dir(&old_file_path, media_dir) {
261                    let _ = std::fs::remove_file(&old_file_path);
262                }
263            }
264
265        // Determine final content and file path for this task
266        let (final_content, final_file_path): (String, Option<String>) =
267            if store_as_file && file_path.is_none() {
268                // Store content to media directory (per-task file)
269                let filename = generate_media_filename(task_id, &name, &mime_type);
270                let media_file_path = media_dir.join(&filename);
271
272                // Ensure media directory exists
273                std::fs::create_dir_all(media_dir)?;
274
275                // Write content to file
276                std::fs::write(&media_file_path, &base_content)?;
277
278                let file_path_str = media_file_path.to_string_lossy().to_string();
279                (String::new(), Some(file_path_str))
280            } else {
281                (base_content.clone(), base_file_path.clone())
282            };
283
284        let order_index = db.add_attachment(
285            task_id,
286            name.clone(),
287            final_content,
288            Some(mime_type.clone()),
289            final_file_path.clone(),
290        )?;
291
292        let mut result = json!({
293            "task_id": task_id,
294            "order_index": order_index
295        });
296
297        if let Some(fp) = final_file_path {
298            result["file_path"] = json!(fp);
299        }
300
301        results.push(result);
302    }
303
304    // Return single result for single task, array for bulk
305    let mut response = if results.len() == 1 {
306        results.into_iter().next().unwrap()
307    } else {
308        json!({ "attachments": results })
309    };
310
311    // Add warning if unknown key behavior is "warn"
312    if let Some(warn_msg) = warning {
313        response["warning"] = json!(warn_msg);
314    }
315
316    Ok(response)
317}
318
319pub fn attachments(
320    db: &Database,
321    _media_dir: &Path,
322    default_format: OutputFormat,
323    args: Value,
324) -> Result<Value> {
325    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
326    let name_pattern = get_string(&args, "name");
327    let mime_pattern = get_string(&args, "mime");
328    let format = get_string(&args, "format")
329        .and_then(|s| OutputFormat::parse(&s))
330        .unwrap_or(default_format);
331
332    // Get filtered attachments (metadata only)
333    let attachments =
334        db.get_attachments_filtered(&task_id, name_pattern.as_deref(), mime_pattern.as_deref())?;
335
336    match format {
337        OutputFormat::Markdown => Ok(markdown_to_json(format_attachments_markdown(&attachments))),
338        OutputFormat::Json => {
339            let results: Vec<Value> = attachments
340                .iter()
341                .map(|a| {
342                    let mut obj = json!({
343                        "task_id": &a.task_id,
344                        "order_index": a.order_index,
345                        "name": a.name,
346                        "mime_type": a.mime_type,
347                        "created_at": a.created_at
348                    });
349
350                    if let Some(ref fp) = a.file_path {
351                        obj["file_path"] = json!(fp);
352                    }
353
354                    obj
355                })
356                .collect();
357
358            Ok(json!({ "attachments": results }))
359        }
360    }
361}
362
363pub fn detach(db: &Database, media_dir: &Path, args: Value) -> Result<Value> {
364    // Agent parameter is optional - for tracking/audit purposes
365    let _agent_id = get_string(&args, "agent");
366
367    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
368    let name = get_string(&args, "name").ok_or_else(|| ToolError::missing_field("name"))?;
369    let delete_file = get_bool(&args, "delete_file").unwrap_or(false);
370
371    // Delete from database (returns whether deleted and file_path if one was set)
372    let (deleted, file_path) = db.delete_attachment_by_name_ex(&task_id, &name)?;
373
374    // If attachment had a file in media dir and delete_file is true, delete it
375    let mut file_deleted = false;
376    if delete_file
377        && let Some(fp) = &file_path
378            && is_in_media_dir(fp, media_dir) {
379                let path = Path::new(fp);
380                if path.exists()
381                    && let Ok(()) = std::fs::remove_file(path) {
382                        file_deleted = true;
383                    }
384            }
385
386    Ok(json!({
387        "success": deleted,
388        "file_deleted": file_deleted
389    }))
390}