1use super::{get_bool, get_string, make_tool_with_prompts};
4use crate::format::{format_attachments_markdown, markdown_to_json, OutputFormat};
5use crate::config::{AttachmentsConfig, Prompts, UnknownKeyBehavior};
6use crate::db::Database;
7use crate::error::{ErrorCode, ToolError};
8use anyhow::Result;
9use rmcp::model::Tool;
10use serde_json::{json, Value};
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
110fn 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 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 let safe_name: String = name
133 .chars()
134 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
135 .collect();
136
137 format!("{}_{}_{}.{}", task_id, safe_name, timestamp, ext)
138}
139
140fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
142 let file_path = Path::new(file_path);
143
144 if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
146 file_abs.starts_with(media_abs)
147 } else {
148 file_path.starts_with(media_dir)
150 }
151}
152
153pub fn attach(db: &Database, media_dir: &Path, attachments_config: &AttachmentsConfig, args: Value) -> Result<Value> {
154 let _agent_id = get_string(&args, "agent");
156
157 let task_ids: Vec<String> = if let Some(task_array) = args.get("task").and_then(|v| v.as_array()) {
159 task_array
160 .iter()
161 .filter_map(|v| v.as_str().map(String::from))
162 .collect()
163 } else if let Some(task_id) = get_string(&args, "task") {
164 vec![task_id]
165 } else {
166 return Err(ToolError::missing_field("task").into());
167 };
168
169 if task_ids.is_empty() {
170 return Err(ToolError::new(ErrorCode::InvalidFieldValue, "At least one task ID must be provided").into());
171 }
172
173 let name = get_string(&args, "name")
174 .ok_or_else(|| ToolError::missing_field("name"))?;
175 let content = get_string(&args, "content");
176 let file_path = get_string(&args, "file");
177 let store_as_file = get_bool(&args, "store_as_file").unwrap_or(false);
178
179 let is_known = attachments_config.is_known_key(&name);
181 let warning: Option<String> = if !is_known {
182 match attachments_config.unknown_key {
183 UnknownKeyBehavior::Reject => {
184 return Err(ToolError::new(
185 ErrorCode::InvalidFieldValue,
186 format!("Unknown attachment key '{}'. Configure it in attachments.definitions or set unknown_key to 'allow' or 'warn'.", name)
187 ).into());
188 }
189 UnknownKeyBehavior::Warn => {
190 Some(format!("Unknown attachment key '{}'", name))
191 }
192 UnknownKeyBehavior::Allow => None,
193 }
194 } else {
195 None
196 };
197
198 let mime_type = get_string(&args, "mime")
200 .unwrap_or_else(|| attachments_config.get_mime_default(&name).to_string());
201 let mode = get_string(&args, "mode")
202 .unwrap_or_else(|| attachments_config.get_mode_default(&name).to_string());
203
204 if mode != "append" && mode != "replace" {
206 return Err(ToolError::new(ErrorCode::InvalidFieldValue, "mode must be 'append' or 'replace'").into());
207 }
208
209 if content.is_none() && file_path.is_none() {
211 return Err(ToolError::new(ErrorCode::InvalidFieldValue, "Either 'content' or 'file' must be provided").into());
212 }
213
214 let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
216 let path = Path::new(fp);
218 if !path.exists() {
219 return Err(ToolError::new(ErrorCode::FileNotFound, format!("File not found: {}", fp)).into());
220 }
221 (String::new(), Some(fp.clone()))
222 } else if store_as_file {
223 (content.clone().unwrap(), None)
225 } else {
226 (content.unwrap(), None)
228 };
229
230 let mut results = Vec::new();
231
232 for task_id in &task_ids {
233 if mode == "replace" {
235 if let Ok(Some(old_file_path)) = db.delete_attachment_by_name(task_id, &name) {
236 if is_in_media_dir(&old_file_path, media_dir) {
238 let _ = std::fs::remove_file(&old_file_path);
239 }
240 }
241 }
242
243 let (final_content, final_file_path): (String, Option<String>) = if store_as_file && file_path.is_none() {
245 let filename = generate_media_filename(task_id, &name, &mime_type);
247 let media_file_path = media_dir.join(&filename);
248
249 std::fs::create_dir_all(media_dir)?;
251
252 std::fs::write(&media_file_path, &base_content)?;
254
255 let file_path_str = media_file_path.to_string_lossy().to_string();
256 (String::new(), Some(file_path_str))
257 } else {
258 (base_content.clone(), base_file_path.clone())
259 };
260
261 let order_index = db.add_attachment(task_id, name.clone(), final_content, Some(mime_type.clone()), final_file_path.clone())?;
262
263 let mut result = json!({
264 "task_id": task_id,
265 "order_index": order_index
266 });
267
268 if let Some(fp) = final_file_path {
269 result["file_path"] = json!(fp);
270 }
271
272 results.push(result);
273 }
274
275 let mut response = if results.len() == 1 {
277 results.into_iter().next().unwrap()
278 } else {
279 json!({ "attachments": results })
280 };
281
282 if let Some(warn_msg) = warning {
284 response["warning"] = json!(warn_msg);
285 }
286
287 Ok(response)
288}
289
290pub fn attachments(db: &Database, _media_dir: &Path, default_format: OutputFormat, args: Value) -> Result<Value> {
291 let task_id = get_string(&args, "task")
292 .ok_or_else(|| ToolError::missing_field("task"))?;
293 let name_pattern = get_string(&args, "name");
294 let mime_pattern = get_string(&args, "mime");
295 let format = get_string(&args, "format")
296 .and_then(|s| OutputFormat::from_str(&s))
297 .unwrap_or(default_format);
298
299 let attachments = db.get_attachments_filtered(
301 &task_id,
302 name_pattern.as_deref(),
303 mime_pattern.as_deref(),
304 )?;
305
306 match format {
307 OutputFormat::Markdown => Ok(markdown_to_json(format_attachments_markdown(&attachments))),
308 OutputFormat::Json => {
309 let results: Vec<Value> = attachments
310 .iter()
311 .map(|a| {
312 let mut obj = json!({
313 "task_id": &a.task_id,
314 "order_index": a.order_index,
315 "name": a.name,
316 "mime_type": a.mime_type,
317 "created_at": a.created_at
318 });
319
320 if let Some(ref fp) = a.file_path {
321 obj["file_path"] = json!(fp);
322 }
323
324 obj
325 })
326 .collect();
327
328 Ok(json!({ "attachments": results }))
329 }
330 }
331}
332
333pub fn detach(db: &Database, media_dir: &Path, args: Value) -> Result<Value> {
334 let _agent_id = get_string(&args, "agent");
336
337 let task_id = get_string(&args, "task")
338 .ok_or_else(|| ToolError::missing_field("task"))?;
339 let name = get_string(&args, "name")
340 .ok_or_else(|| ToolError::missing_field("name"))?;
341 let delete_file = get_bool(&args, "delete_file").unwrap_or(false);
342
343 let (deleted, file_path) = db.delete_attachment_by_name_ex(&task_id, &name)?;
345
346 let mut file_deleted = false;
348 if delete_file {
349 if let Some(fp) = &file_path {
350 if is_in_media_dir(fp, media_dir) {
351 let path = Path::new(fp);
352 if path.exists() {
353 if let Ok(()) = std::fs::remove_file(path) {
354 file_deleted = true;
355 }
356 }
357 }
358 }
359 }
360
361 Ok(json!({
362 "success": deleted,
363 "file_deleted": file_deleted
364 }))
365}