Skip to main content

task_graph_mcp/tools/
attachments.rs

1//! Attachment management tools.
2
3use super::{get_bool, get_string, get_string_or_array, 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/// Validate a MIME type string per RFC 6838 basics.
116///
117/// Requires exactly one `/` separating non-empty type and subtype,
118/// each at most 127 bytes, using only restricted-name characters:
119/// alphanumeric, `!`, `#`, `$`, `&`, `-`, `^`, `_`, `.`, `+`.
120fn validate_mime_type(mime: &str) -> Result<()> {
121    let parts: Vec<&str> = mime.split('/').collect();
122    if parts.len() != 2 {
123        return Err(ToolError::new(
124            ErrorCode::InvalidFieldValue,
125            format!("Invalid MIME type '{}': must contain exactly one '/'", mime),
126        )
127        .into());
128    }
129    let (type_part, subtype_part) = (parts[0], parts[1]);
130    if type_part.is_empty() || subtype_part.is_empty() {
131        return Err(ToolError::new(
132            ErrorCode::InvalidFieldValue,
133            format!(
134                "Invalid MIME type '{}': type and subtype must be non-empty",
135                mime
136            ),
137        )
138        .into());
139    }
140    if type_part.len() > 127 || subtype_part.len() > 127 {
141        return Err(ToolError::new(
142            ErrorCode::InvalidFieldValue,
143            format!(
144                "Invalid MIME type '{}': type and subtype must be at most 127 bytes",
145                mime
146            ),
147        )
148        .into());
149    }
150    let is_valid_char = |c: char| -> bool {
151        c.is_ascii_alphanumeric()
152            || matches!(c, '!' | '#' | '$' | '&' | '-' | '^' | '_' | '.' | '+')
153    };
154    for (label, part) in [("type", type_part), ("subtype", subtype_part)] {
155        if let Some(bad) = part.chars().find(|c| !is_valid_char(*c)) {
156            return Err(ToolError::new(
157                ErrorCode::InvalidFieldValue,
158                format!(
159                    "Invalid MIME type '{}': {} contains invalid character '{}'",
160                    mime, label, bad
161                ),
162            )
163            .into());
164        }
165    }
166    Ok(())
167}
168
169/// Maximum filename length — universal limit across ext4, NTFS, APFS.
170/// Since sanitized filenames are pure ASCII, bytes == chars == UTF-16 units.
171const MAX_FILENAME_LEN: usize = 255;
172
173/// Generate a unique filename for media storage.
174fn generate_media_filename(task_id: &str, attachment_type: &str, mime_type: &str) -> String {
175    let timestamp = std::time::SystemTime::now()
176        .duration_since(std::time::UNIX_EPOCH)
177        .map(|d| d.as_millis())
178        .unwrap_or(0);
179
180    // Determine extension from mime type
181    let ext = match mime_type {
182        "application/json" => "json",
183        "text/plain" => "txt",
184        "text/markdown" => "md",
185        "text/html" => "html",
186        "image/png" => "png",
187        "image/jpeg" => "jpg",
188        "image/gif" => "gif",
189        "image/webp" => "webp",
190        "application/pdf" => "pdf",
191        _ => "bin",
192    };
193
194    // Sanitize type for filename
195    let safe_type: String = attachment_type
196        .chars()
197        .map(|c| {
198            if c.is_alphanumeric() || c == '-' || c == '_' {
199                c
200            } else {
201                '_'
202            }
203        })
204        .collect();
205
206    // Truncate safe_type to fit within MAX_FILENAME_LEN.
207    // Fixed parts: {task_id}_{safe_type}_{timestamp}.{ext}
208    //   separators: 2 underscores + 1 dot = 3
209    //   timestamp: up to 13 digits for millis (until year 2286)
210    let timestamp_str = timestamp.to_string();
211    let fixed_len = task_id.len() + 1 + 1 + timestamp_str.len() + 1 + ext.len();
212    // budget = MAX_FILENAME_LEN - fixed_len (for safe_type portion)
213    let safe_type = if fixed_len >= MAX_FILENAME_LEN {
214        // No room for safe_type at all — still unique via timestamp
215        String::new()
216    } else {
217        let budget = MAX_FILENAME_LEN - fixed_len;
218        if safe_type.len() > budget {
219            safe_type[..budget].to_string()
220        } else {
221            safe_type
222        }
223    };
224
225    format!("{}_{}_{}.{}", task_id, safe_type, timestamp_str, ext)
226}
227
228/// Check if a file path is within the media directory.
229fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
230    let file_path = Path::new(file_path);
231
232    // Try to canonicalize both paths for comparison
233    if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
234        file_abs.starts_with(media_abs)
235    } else {
236        // Fall back to string prefix check
237        file_path.starts_with(media_dir)
238    }
239}
240
241pub fn attach(
242    db: &Database,
243    media_dir: &Path,
244    attachments_config: &AttachmentsConfig,
245    args: Value,
246) -> Result<Value> {
247    // Agent parameter is optional - for tracking/audit purposes
248    let _agent_id = get_string(&args, "agent");
249
250    let task_ids =
251        get_string_or_array(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
252
253    if task_ids.is_empty() {
254        return Err(ToolError::new(
255            ErrorCode::InvalidFieldValue,
256            "At least one task ID must be provided",
257        )
258        .into());
259    }
260
261    let attachment_type =
262        get_string(&args, "type").ok_or_else(|| ToolError::missing_field("type"))?;
263    let name = get_string(&args, "name").unwrap_or_default();
264    let content = get_string(&args, "content");
265    let file_path = get_string(&args, "file");
266    let store_as_file = get_bool(&args, "store_as_file").unwrap_or(false);
267
268    // Check if this is a known key and handle unknown_key behavior
269    let is_known = attachments_config.is_known_key(&attachment_type);
270    let warning: Option<String> = if !is_known {
271        match attachments_config.unknown_key {
272            UnknownKeyBehavior::Reject => {
273                return Err(ToolError::new(
274                    ErrorCode::InvalidFieldValue,
275                    format!("Unknown attachment type '{}'. Configure it in attachments.definitions or set unknown_key to 'allow' or 'warn'.", attachment_type)
276                ).into());
277            }
278            UnknownKeyBehavior::Warn => {
279                Some(format!("Unknown attachment type '{}'", attachment_type))
280            }
281            UnknownKeyBehavior::Allow => None,
282        }
283    } else {
284        None
285    };
286
287    // Use config defaults for mime/mode, but allow explicit overrides from args
288    let mime_type = get_string(&args, "mime").unwrap_or_else(|| {
289        attachments_config
290            .get_mime_default(&attachment_type)
291            .to_string()
292    });
293    validate_mime_type(&mime_type)?;
294
295    let mode = get_string(&args, "mode").unwrap_or_else(|| {
296        attachments_config
297            .get_mode_default(&attachment_type)
298            .to_string()
299    });
300
301    // Validate mode
302    if mode != "append" && mode != "replace" {
303        return Err(ToolError::new(
304            ErrorCode::InvalidFieldValue,
305            "mode must be 'append' or 'replace'",
306        )
307        .into());
308    }
309
310    // Validate: need either content or file
311    if content.is_none() && file_path.is_none() {
312        return Err(ToolError::new(
313            ErrorCode::InvalidFieldValue,
314            "Either 'content' or 'file' must be provided",
315        )
316        .into());
317    }
318
319    // Handle different attachment modes - prepare content/file once for all tasks
320    let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
321        // File reference mode: verify file exists
322        let path = Path::new(fp);
323        if !path.exists() {
324            return Err(
325                ToolError::new(ErrorCode::FileNotFound, format!("File not found: {}", fp)).into(),
326            );
327        }
328        (String::new(), Some(fp.clone()))
329    } else if store_as_file {
330        // For store_as_file with multiple tasks, we'll create per-task files
331        (content.clone().unwrap(), None)
332    } else {
333        // Inline content mode
334        (content.unwrap(), None)
335    };
336
337    let mut results = Vec::new();
338
339    for task_id in &task_ids {
340        // Replace mode: delete all existing attachments of this type before adding new one
341        if mode == "replace" {
342            let old_file_paths = db.delete_attachments_by_type(task_id, &attachment_type)?;
343            // Clean up old media files if they were in media dir
344            for old_fp in old_file_paths {
345                if is_in_media_dir(&old_fp, media_dir) {
346                    let _ = std::fs::remove_file(&old_fp);
347                }
348            }
349        }
350
351        // Determine final content and file path for this task
352        let (final_content, final_file_path): (String, Option<String>) =
353            if store_as_file && file_path.is_none() {
354                // Store content to media directory (per-task file)
355                let filename = generate_media_filename(task_id, &attachment_type, &mime_type);
356                let media_file_path = media_dir.join(&filename);
357
358                // Ensure media directory exists
359                std::fs::create_dir_all(media_dir)?;
360
361                // Write content to file
362                std::fs::write(&media_file_path, &base_content)?;
363
364                let file_path_str = media_file_path.to_string_lossy().to_string();
365                (String::new(), Some(file_path_str))
366            } else {
367                (base_content.clone(), base_file_path.clone())
368            };
369
370        let sequence = db.add_attachment(
371            task_id,
372            attachment_type.clone(),
373            name.clone(),
374            final_content,
375            Some(mime_type.clone()),
376            final_file_path.clone(),
377        )?;
378
379        let mut result = json!({
380            "task_id": task_id,
381            "type": &attachment_type,
382            "sequence": sequence
383        });
384
385        if !name.is_empty() {
386            result["name"] = json!(&name);
387        }
388
389        if let Some(fp) = final_file_path {
390            result["file_path"] = json!(fp);
391        }
392
393        results.push(result);
394    }
395
396    // Return single result for single task, array for bulk
397    let mut response = if results.len() == 1 {
398        results.into_iter().next().unwrap()
399    } else {
400        json!({ "attachments": results })
401    };
402
403    // Add warning if unknown key behavior is "warn"
404    if let Some(warn_msg) = warning {
405        response["warning"] = json!(warn_msg);
406    }
407
408    Ok(response)
409}
410
411pub fn attachments(
412    db: &Database,
413    _media_dir: &Path,
414    default_format: OutputFormat,
415    args: Value,
416) -> Result<Value> {
417    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
418    let type_pattern = get_string(&args, "type");
419    let mime_pattern = get_string(&args, "mime");
420    let format = get_string(&args, "format")
421        .and_then(|s| OutputFormat::parse(&s))
422        .unwrap_or(default_format);
423
424    // Get filtered attachments (metadata only)
425    let attachments =
426        db.get_attachments_filtered(&task_id, type_pattern.as_deref(), mime_pattern.as_deref())?;
427
428    match format {
429        OutputFormat::Markdown => Ok(markdown_to_json(format_attachments_markdown(&attachments))),
430        OutputFormat::Json => {
431            let results: Vec<Value> = attachments
432                .iter()
433                .map(|a| {
434                    let mut obj = json!({
435                        "task_id": &a.task_id,
436                        "type": &a.attachment_type,
437                        "sequence": a.sequence,
438                        "name": &a.name,
439                        "mime_type": &a.mime_type,
440                        "created_at": a.created_at
441                    });
442
443                    if let Some(ref fp) = a.file_path {
444                        obj["file_path"] = json!(fp);
445                    }
446
447                    obj
448                })
449                .collect();
450
451            Ok(json!({ "attachments": results }))
452        }
453    }
454}
455
456pub fn detach(db: &Database, media_dir: &Path, args: Value) -> Result<Value> {
457    // Agent parameter is optional - for tracking/audit purposes
458    let _agent_id = get_string(&args, "agent");
459
460    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
461    let attachment_type =
462        get_string(&args, "type").ok_or_else(|| ToolError::missing_field("type"))?;
463    let delete_files = get_bool(&args, "delete_files").unwrap_or(false);
464
465    // Delete from database (returns count and file_paths)
466    let (deleted_count, file_paths) =
467        db.delete_attachments_by_type_ex(&task_id, &attachment_type)?;
468
469    // If delete_files is true, delete files that were in media dir
470    let mut files_deleted = 0;
471    if delete_files {
472        for fp in &file_paths {
473            if is_in_media_dir(fp, media_dir) {
474                let path = Path::new(fp);
475                if path.exists() && std::fs::remove_file(path).is_ok() {
476                    files_deleted += 1;
477                }
478            }
479        }
480    }
481
482    Ok(json!({
483        "deleted_count": deleted_count,
484        "files_deleted": files_deleted
485    }))
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    // --- validate_mime_type tests ---
493
494    #[test]
495    fn test_mime_valid_standard() {
496        assert!(validate_mime_type("text/plain").is_ok());
497        assert!(validate_mime_type("application/json").is_ok());
498        assert!(validate_mime_type("image/png").is_ok());
499    }
500
501    #[test]
502    fn test_mime_valid_special_chars() {
503        // dot, dash, plus are valid restricted-name-chars
504        assert!(validate_mime_type("text/git.hash").is_ok());
505        assert!(validate_mime_type("text/x-diff").is_ok());
506        assert!(validate_mime_type("text/p4.changelist").is_ok());
507        assert!(validate_mime_type("application/vnd.api+json").is_ok());
508    }
509
510    #[test]
511    fn test_mime_missing_slash() {
512        assert!(validate_mime_type("textplain").is_err());
513    }
514
515    #[test]
516    fn test_mime_empty_parts() {
517        assert!(validate_mime_type("/plain").is_err());
518        assert!(validate_mime_type("text/").is_err());
519        assert!(validate_mime_type("/").is_err());
520    }
521
522    #[test]
523    fn test_mime_multiple_slashes() {
524        assert!(validate_mime_type("text/plain/extra").is_err());
525    }
526
527    #[test]
528    fn test_mime_invalid_chars() {
529        assert!(validate_mime_type("text/pla in").is_err()); // space
530        assert!(validate_mime_type("text/pla@in").is_err()); // @
531        assert!(validate_mime_type("text/pla{in").is_err()); // {
532    }
533
534    #[test]
535    fn test_mime_too_long_parts() {
536        let long = "a".repeat(128);
537        assert!(validate_mime_type(&format!("{}/plain", long)).is_err());
538        assert!(validate_mime_type(&format!("text/{}", long)).is_err());
539        // Exactly 127 is fine
540        let max = "a".repeat(127);
541        assert!(validate_mime_type(&format!("{}/plain", max)).is_ok());
542    }
543
544    // --- generate_media_filename tests ---
545
546    #[test]
547    fn test_filename_basic_format() {
548        let name = generate_media_filename("task-1", "note", "text/plain");
549        assert!(name.starts_with("task-1_note_"));
550        assert!(name.ends_with(".txt"));
551    }
552
553    #[test]
554    fn test_filename_sanitization() {
555        let name = generate_media_filename("t1", "my type/here", "text/plain");
556        // Spaces and slashes become underscores
557        assert!(name.starts_with("t1_my_type_here_"));
558    }
559
560    #[test]
561    fn test_filename_extension_mapping() {
562        assert!(generate_media_filename("t", "x", "application/json").ends_with(".json"));
563        assert!(generate_media_filename("t", "x", "text/markdown").ends_with(".md"));
564        assert!(generate_media_filename("t", "x", "image/png").ends_with(".png"));
565        assert!(generate_media_filename("t", "x", "image/jpeg").ends_with(".jpg"));
566        assert!(generate_media_filename("t", "x", "unknown/type").ends_with(".bin"));
567    }
568
569    #[test]
570    fn test_filename_length_limit() {
571        let long_type = "a".repeat(300);
572        let name = generate_media_filename("task-1", &long_type, "text/plain");
573        assert!(
574            name.len() <= MAX_FILENAME_LEN,
575            "filename length {} exceeds {}",
576            name.len(),
577            MAX_FILENAME_LEN
578        );
579        // Should still have the expected structure
580        assert!(name.starts_with("task-1_"));
581        assert!(name.ends_with(".txt"));
582    }
583
584    #[test]
585    fn test_filename_long_task_id() {
586        // Even with a very long task_id, filename should still be bounded
587        let long_id = "x".repeat(250);
588        let name = generate_media_filename(&long_id, "note", "text/plain");
589        // safe_type will be empty since task_id eats the budget
590        assert!(name.len() <= MAX_FILENAME_LEN || name.starts_with(&long_id));
591    }
592
593    #[test]
594    fn test_filename_empty_type() {
595        let name = generate_media_filename("task-1", "", "text/plain");
596        assert!(name.starts_with("task-1_"));
597        assert!(name.ends_with(".txt"));
598    }
599}