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 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| {
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
146fn is_in_media_dir(file_path: &str, media_dir: &Path) -> bool {
148 let file_path = Path::new(file_path);
149
150 if let (Ok(file_abs), Ok(media_abs)) = (file_path.canonicalize(), media_dir.canonicalize()) {
152 file_abs.starts_with(media_abs)
153 } else {
154 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 let _agent_id = get_string(&args, "agent");
167
168 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 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 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 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 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 let (base_content, base_file_path): (String, Option<String>) = if let Some(ref fp) = file_path {
237 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 (content.clone().unwrap(), None)
248 } else {
249 (content.unwrap(), None)
251 };
252
253 let mut results = Vec::new();
254
255 for task_id in &task_ids {
256 if mode == "replace"
258 && let Ok(Some(old_file_path)) = db.delete_attachment_by_name(task_id, &name) {
259 if is_in_media_dir(&old_file_path, media_dir) {
261 let _ = std::fs::remove_file(&old_file_path);
262 }
263 }
264
265 let (final_content, final_file_path): (String, Option<String>) =
267 if store_as_file && file_path.is_none() {
268 let filename = generate_media_filename(task_id, &name, &mime_type);
270 let media_file_path = media_dir.join(&filename);
271
272 std::fs::create_dir_all(media_dir)?;
274
275 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 let mut response = if results.len() == 1 {
306 results.into_iter().next().unwrap()
307 } else {
308 json!({ "attachments": results })
309 };
310
311 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 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 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 let (deleted, file_path) = db.delete_attachment_by_name_ex(&task_id, &name)?;
373
374 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}