1use 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
115fn 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
169const MAX_FILENAME_LEN: usize = 255;
172
173fn 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 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 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 let timestamp_str = timestamp.to_string();
211 let fixed_len = task_id.len() + 1 + 1 + timestamp_str.len() + 1 + ext.len();
212 let safe_type = if fixed_len >= MAX_FILENAME_LEN {
214 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
228fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
230 let file_path = Path::new(file_path);
231
232 if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
234 file_abs.starts_with(media_abs)
235 } else {
236 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 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 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 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 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 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 let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
321 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 (content.clone().unwrap(), None)
332 } else {
333 (content.unwrap(), None)
335 };
336
337 let mut results = Vec::new();
338
339 for task_id in &task_ids {
340 if mode == "replace" {
342 let old_file_paths = db.delete_attachments_by_type(task_id, &attachment_type)?;
343 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 let (final_content, final_file_path): (String, Option<String>) =
353 if store_as_file && file_path.is_none() {
354 let filename = generate_media_filename(task_id, &attachment_type, &mime_type);
356 let media_file_path = media_dir.join(&filename);
357
358 std::fs::create_dir_all(media_dir)?;
360
361 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 let mut response = if results.len() == 1 {
398 results.into_iter().next().unwrap()
399 } else {
400 json!({ "attachments": results })
401 };
402
403 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 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 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 let (deleted_count, file_paths) =
467 db.delete_attachments_by_type_ex(&task_id, &attachment_type)?;
468
469 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 #[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 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()); assert!(validate_mime_type("text/pla@in").is_err()); assert!(validate_mime_type("text/pla{in").is_err()); }
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 let max = "a".repeat(127);
541 assert!(validate_mime_type(&format!("{}/plain", max)).is_ok());
542 }
543
544 #[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 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 assert!(name.starts_with("task-1_"));
581 assert!(name.ends_with(".txt"));
582 }
583
584 #[test]
585 fn test_filename_long_task_id() {
586 let long_id = "x".repeat(250);
588 let name = generate_media_filename(&long_id, "note", "text/plain");
589 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}