1use 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
115fn 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 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 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
151fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
153 let file_path = Path::new(file_path);
154
155 if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
157 file_abs.starts_with(media_abs)
158 } else {
159 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 let _agent_id = get_string(&args, "agent");
172
173 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 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 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 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 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 let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
252 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 (content.clone().unwrap(), None)
263 } else {
264 (content.unwrap(), None)
266 };
267
268 let mut results = Vec::new();
269
270 for task_id in &task_ids {
271 if mode == "replace" {
273 let old_file_paths = db.delete_attachments_by_type(task_id, &attachment_type)?;
274 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 let (final_content, final_file_path): (String, Option<String>) =
284 if store_as_file && file_path.is_none() {
285 let filename = generate_media_filename(task_id, &attachment_type, &mime_type);
287 let media_file_path = media_dir.join(&filename);
288
289 std::fs::create_dir_all(media_dir)?;
291
292 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 let mut response = if results.len() == 1 {
329 results.into_iter().next().unwrap()
330 } else {
331 json!({ "attachments": results })
332 };
333
334 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 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 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 let (deleted_count, file_paths) =
398 db.delete_attachments_by_type_ex(&task_id, &attachment_type)?;
399
400 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}