Skip to main content

task_graph_mcp/tools/
mod.rs

1//! MCP tool implementations.
2
3pub mod advisories;
4pub mod agents;
5pub mod attachments;
6pub mod claiming;
7pub mod context;
8pub mod deps;
9pub mod feedback;
10pub mod files;
11pub mod gates;
12pub mod prompts_tool;
13pub mod query;
14pub mod schema;
15pub mod search;
16pub mod skills;
17pub mod tasks;
18pub mod tracking;
19pub mod workflows;
20
21pub use context::ToolContext;
22
23use crate::config::{AppConfig, Prompts, ServerPaths, workflows::WorkflowsConfig};
24use crate::db::Database;
25use crate::error::ToolError;
26use crate::format::{OutputFormat, ToolResult};
27use anyhow::Result;
28use rmcp::model::Tool;
29use serde_json::Value;
30use std::path::PathBuf;
31use std::sync::Arc;
32
33/// Tool handler that processes MCP tool calls.
34pub struct ToolHandler {
35    pub db: Arc<Database>,
36    pub media_dir: PathBuf,
37    pub skills_dir: PathBuf,
38    pub server_paths: Arc<ServerPaths>,
39    pub prompts: Arc<Prompts>,
40    /// Consolidated application configuration.
41    pub config: AppConfig,
42    pub default_format: OutputFormat,
43    pub default_page_size: i32,
44    pub path_mapper: Arc<crate::paths::PathMapper>,
45}
46
47impl ToolHandler {
48    #[allow(clippy::too_many_arguments)]
49    pub fn new(
50        db: Arc<Database>,
51        media_dir: PathBuf,
52        skills_dir: PathBuf,
53        server_paths: Arc<ServerPaths>,
54        prompts: Arc<Prompts>,
55        config: AppConfig,
56        default_format: OutputFormat,
57        default_page_size: i32,
58        path_mapper: Arc<crate::paths::PathMapper>,
59    ) -> Self {
60        Self {
61            db,
62            media_dir,
63            skills_dir,
64            server_paths,
65            prompts,
66            config,
67            default_format,
68            default_page_size,
69            path_mapper,
70        }
71    }
72
73    /// Get the workflow config for a worker.
74    /// Looks up the worker's workflow name and returns the corresponding config,
75    /// or falls back to the configured default workflow, or the base config.
76    /// If the worker has overlays, applies them on top of the base workflow
77    /// and caches the merged result for reuse.
78    pub fn get_workflow_for_worker(&self, worker_id: &str) -> Arc<WorkflowsConfig> {
79        if let Ok(Some(worker)) = self.db.get_worker(worker_id) {
80            // Resolve the base workflow
81            let base = if let Some(ref workflow_name) = worker.workflow {
82                self.config
83                    .workflows
84                    .get_named_workflow(workflow_name)
85                    .map(Arc::clone)
86            } else {
87                None
88            }
89            .or_else(|| self.config.workflows.get_default_workflow().map(Arc::clone))
90            .unwrap_or_else(|| Arc::clone(&self.config.workflows));
91
92            // If the worker has overlays, build a merged config
93            if !worker.overlays.is_empty() {
94                // Check cache first (composite key: "workflow+overlay1+overlay2")
95                let cache_key = format!(
96                    "{}+{}",
97                    worker.workflow.as_deref().unwrap_or("default"),
98                    worker.overlays.join("+")
99                );
100                if let Some(cached) = self.config.workflows.get_named_workflow(&cache_key) {
101                    return Arc::clone(cached);
102                }
103
104                // Build merged workflow by applying overlays in order
105                let mut merged = (*base).clone();
106                for name in &worker.overlays {
107                    if let Some(overlay) = self.config.workflows.named_overlays.get(name) {
108                        merged.apply_overlay(overlay);
109                    }
110                }
111                merged.active_overlays = worker.overlays.clone();
112                return Arc::new(merged);
113            }
114
115            return base;
116        }
117        // Fall back to configured default workflow, or base config
118        if let Some(default_workflow) = self.config.workflows.get_default_workflow() {
119            Arc::clone(default_workflow)
120        } else {
121            Arc::clone(&self.config.workflows)
122        }
123    }
124
125    /// Get all available tools.
126    pub fn get_tools(&self) -> Vec<Tool> {
127        let mut tools = Vec::new();
128
129        // Worker tools
130        tools.extend(agents::get_tools(&self.prompts));
131
132        // Task tools (with dynamic state schema)
133        tools.extend(tasks::get_tools(&self.prompts, &self.config.states));
134
135        // Tracking tools
136        tools.extend(tracking::get_tools(&self.prompts, &self.config.states));
137
138        // Dependency tools
139        tools.extend(deps::get_tools(&self.prompts, &self.config.deps));
140
141        // Claiming tools (with dynamic state schema)
142        tools.extend(claiming::get_tools(&self.prompts, &self.config.states));
143
144        // File coordination tools
145        tools.extend(files::get_tools(&self.prompts));
146
147        // Attachment tools
148        tools.extend(attachments::get_tools(&self.prompts));
149
150        // Skill tools (no prompts needed, always available)
151        tools.extend(skills::get_tools());
152
153        // Schema introspection tools
154        tools.extend(schema::get_tools());
155
156        // Search tools
157        tools.extend(search::get_tools(&self.prompts));
158
159        // Query tools (read-only SQL)
160        tools.extend(query::get_tools());
161
162        // Gate checking tools
163        tools.extend(gates::get_tools(&self.prompts));
164
165        // Workflow discovery tools (no auth needed, callable before connect)
166        tools.extend(workflows::get_tools());
167
168        // Advisory tools
169        tools.extend(advisories::get_tools());
170
171        // Prompt query tools
172        tools.extend(prompts_tool::get_tools());
173
174        // Feedback tools (conditionally enabled)
175        if self.config.feedback.enabled {
176            tools.extend(feedback::get_tools());
177        }
178
179        tools
180    }
181
182    /// Call a tool by name.
183    #[allow(unused_variables)]
184    pub async fn call_tool(
185        &self,
186        name: &str,
187        arguments: Value,
188        ctx: &ToolContext,
189    ) -> Result<ToolResult> {
190        // Helper to wrap JSON results
191        let json = |r: Result<Value>| r.map(ToolResult::Json);
192
193        match name {
194            // Worker tools
195            "connect" => {
196                // Resolve base workflow from args (worker isn't registered yet during connect)
197                let base_workflow = arguments
198                    .get("workflow")
199                    .and_then(|v| v.as_str())
200                    .and_then(|name| self.config.workflows.get_named_workflow(name))
201                    .map(Arc::clone)
202                    .or_else(|| self.config.workflows.get_default_workflow().map(Arc::clone))
203                    .unwrap_or_else(|| Arc::clone(&self.config.workflows));
204
205                // Apply overlays if specified
206                let overlay_names: Vec<String> = arguments
207                    .get("overlays")
208                    .and_then(|v| v.as_array())
209                    .map(|arr| {
210                        arr.iter()
211                            .filter_map(|v| v.as_str().map(String::from))
212                            .collect()
213                    })
214                    .unwrap_or_default();
215
216                let workflow = if overlay_names.is_empty() {
217                    base_workflow
218                } else {
219                    let mut merged = (*base_workflow).clone();
220                    for name in &overlay_names {
221                        if let Some(overlay) = self.config.workflows.named_overlays.get(name) {
222                            merged.apply_overlay(overlay);
223                        }
224                    }
225                    merged.active_overlays = overlay_names;
226                    Arc::new(merged)
227                };
228
229                json(agents::connect(
230                    agents::ConnectOptions {
231                        db: &self.db,
232                        server_paths: &self.server_paths,
233                        config: &self.config,
234                        workflows: &workflow,
235                    },
236                    arguments,
237                ))
238            }
239            "disconnect" => json(agents::disconnect(&self.db, &self.config.states, arguments)),
240            "list_agents" => agents::list_agents(
241                &self.db,
242                &self.config.states,
243                self.default_format,
244                arguments,
245            ),
246            "cleanup_stale" => json(agents::cleanup_stale(
247                &self.db,
248                &self.config.states,
249                arguments,
250            )),
251            "add_overlay" => json(agents::add_overlay(&self.db, &self.config, arguments)),
252            "remove_overlay" => json(agents::remove_overlay(&self.db, &self.config, arguments)),
253
254            // Task tools
255            "create" => json(tasks::create(&self.db, &self.config, arguments)),
256            "create_tree" => json(tasks::create_tree(&self.db, &self.config, arguments)),
257            "get" => tasks::get(&self.db, self.default_format, arguments),
258            "list_tasks" => tasks::list_tasks(
259                &self.db,
260                &self.config.states,
261                &self.config.deps,
262                self.default_format,
263                arguments,
264            ),
265            "update" => {
266                // Look up worker's workflow for prompts
267                let worker_id = arguments
268                    .get("worker_id")
269                    .and_then(|v| v.as_str())
270                    .unwrap_or("");
271                let workflow = self.get_workflow_for_worker(worker_id);
272                json(tasks::update(
273                    tasks::UpdateOptions {
274                        db: &self.db,
275                        config: &self.config,
276                        workflows: &workflow,
277                    },
278                    arguments,
279                ))
280            }
281            "bulk_update" => {
282                // Look up worker's workflow for prompts
283                let worker_id = arguments
284                    .get("worker_id")
285                    .and_then(|v| v.as_str())
286                    .unwrap_or("");
287                let workflow = self.get_workflow_for_worker(worker_id);
288                json(tasks::bulk_update(
289                    tasks::UpdateOptions {
290                        db: &self.db,
291                        config: &self.config,
292                        workflows: &workflow,
293                    },
294                    arguments,
295                ))
296            }
297            "delete" => json(tasks::delete(&self.db, arguments)),
298            "rename" => json(tasks::rename(&self.db, arguments)),
299            "scan" => tasks::scan(&self.db, self.default_format, arguments),
300            "status_summary" => json(tasks::status_summary(
301                &self.db,
302                &self.config.states,
303                arguments,
304            )),
305
306            // Tracking tools
307            "thinking" => json(tracking::thinking(&self.db, &self.config.states, arguments)),
308            "task_history" => tracking::task_history(
309                &self.db,
310                &self.config.states,
311                self.default_format,
312                arguments,
313            ),
314            "log_metrics" => json(tracking::log_metrics(&self.db, arguments)),
315            "get_metrics" => json(tracking::get_metrics(&self.db, arguments)),
316            "project_history" => {
317                tracking::project_history(&self.db, self.default_format, arguments)
318            }
319
320            // Dependency tools
321            "link" => json(deps::link(&self.db, &self.config.deps, arguments)),
322            "unlink" => json(deps::unlink(&self.db, arguments)),
323            "relink" => json(deps::relink(&self.db, &self.config.deps, arguments)),
324
325            // Claiming tools
326            "claim" => {
327                // Look up worker's workflow for prompts
328                let worker_id = arguments
329                    .get("worker_id")
330                    .and_then(|v| v.as_str())
331                    .unwrap_or("");
332                let workflow = self.get_workflow_for_worker(worker_id);
333                json(claiming::claim(
334                    &self.db,
335                    &self.config,
336                    &workflow,
337                    arguments,
338                ))
339            }
340
341            // File coordination tools
342            "mark_file" => json(files::mark_file(&self.db, arguments)),
343            "unmark_file" => json(files::unmark_file(&self.db, arguments)),
344            "list_marks" => files::list_marks(&self.db, self.default_format, arguments),
345            "mark_updates" => {
346                json(files::mark_updates_async(std::sync::Arc::clone(&self.db), arguments).await)
347            }
348
349            // Attachment tools
350            "attach" => json(attachments::attach(
351                &self.db,
352                &self.media_dir,
353                &self.config.attachments,
354                arguments,
355            )),
356            "attachments" => {
357                attachments::attachments(&self.db, &self.media_dir, self.default_format, arguments)
358            }
359            "detach" => json(attachments::detach(&self.db, &self.media_dir, arguments)),
360
361            // Skill tools
362            name if skills::is_skill_tool(name) => {
363                json(skills::call_tool(&self.skills_dir, name, &arguments))
364            }
365
366            // Schema introspection tools
367            "get_schema" => json(schema::get_schema(&self.db, arguments)),
368
369            // Search tools
370            "search" => json(search::search(&self.db, self.default_page_size, arguments)),
371
372            // Query tools (read-only SQL)
373            "query" => query::query(&self.db, self.default_format, arguments),
374
375            // Gate checking tools
376            "check_gates" => {
377                // Look up worker's workflow for gate definitions
378                // Since check_gates doesn't require worker_id, use base workflow
379                json(gates::check_gates(
380                    &self.db,
381                    &self.config.workflows,
382                    arguments,
383                ))
384            }
385
386            // Advisory tools
387            "get_advisory" => {
388                // Look up worker's workflow for advisory definitions
389                let worker_id = arguments
390                    .get("worker_id")
391                    .and_then(|v| v.as_str())
392                    .unwrap_or("");
393                let workflow = self.get_workflow_for_worker(worker_id);
394                json(advisories::get_advisory(&self.db, &workflow, arguments))
395            }
396
397            // Prompt query tools
398            "get_prompts" => {
399                let worker_id = arguments
400                    .get("worker_id")
401                    .and_then(|v| v.as_str())
402                    .unwrap_or("");
403                let workflow = self.get_workflow_for_worker(worker_id);
404                json(prompts_tool::get_prompts(&self.db, &workflow, arguments))
405            }
406
407            // Workflow discovery tools (no connection required)
408            "list_workflows" => json(workflows::list_workflows(&self.config.workflows)),
409
410            // Feedback tools (gated by config)
411            "give_feedback" | "list_feedback" if !self.config.feedback.enabled => {
412                Err(ToolError::unknown_tool(name).into())
413            }
414            "give_feedback" => {
415                let db_dir = self
416                    .server_paths
417                    .db_path
418                    .parent()
419                    .unwrap_or(std::path::Path::new("."));
420                json(feedback::give_feedback(
421                    db_dir,
422                    &self.config.feedback,
423                    Some(&self.db),
424                    arguments,
425                ))
426            }
427            "list_feedback" => {
428                let db_dir = self
429                    .server_paths
430                    .db_path
431                    .parent()
432                    .unwrap_or(std::path::Path::new("."));
433                json(feedback::list_feedback(db_dir))
434            }
435
436            _ => Err(ToolError::unknown_tool(name).into()),
437        }
438    }
439}
440
441/// Helper to create a tool definition.
442pub fn make_tool(name: &str, description: &str, properties: Value, required: Vec<&str>) -> Tool {
443    let input_schema = rmcp::model::JsonObject::from_iter([
444        ("type".to_string(), serde_json::json!("object")),
445        ("properties".to_string(), properties),
446        ("required".to_string(), serde_json::json!(required)),
447    ]);
448
449    Tool::new(name.to_string(), description.to_string(), input_schema)
450}
451
452/// Helper to create a tool definition with prompt overrides.
453/// Looks up the tool description in prompts, falls back to default_description.
454pub fn make_tool_with_prompts(
455    name: &str,
456    default_description: &str,
457    properties: Value,
458    required: Vec<&str>,
459    prompts: &Prompts,
460) -> Tool {
461    let description = prompts
462        .get_tool_description(name)
463        .unwrap_or(default_description);
464    make_tool(name, description, properties, required)
465}
466
467/// Helper to get a string from arguments.
468pub fn get_string(args: &Value, key: &str) -> Option<String> {
469    args.get(key).and_then(|v| v.as_str().map(String::from))
470}
471
472/// Helper to get an i32 from arguments.
473pub fn get_i32(args: &Value, key: &str) -> Option<i32> {
474    args.get(key).and_then(|v| v.as_i64().map(|n| n as i32))
475}
476
477/// Helper to get an i64 from arguments.
478pub fn get_i64(args: &Value, key: &str) -> Option<i64> {
479    args.get(key).and_then(|v| v.as_i64())
480}
481
482/// Helper to get an f64 from arguments.
483pub fn get_f64(args: &Value, key: &str) -> Option<f64> {
484    args.get(key).and_then(|v| v.as_f64())
485}
486
487/// Helper to get a bool from arguments.
488pub fn get_bool(args: &Value, key: &str) -> Option<bool> {
489    args.get(key).and_then(|v| v.as_bool())
490}
491
492/// Helper to get a string array from arguments.
493pub fn get_string_array(args: &Value, key: &str) -> Option<Vec<String>> {
494    args.get(key).and_then(|v| {
495        v.as_array().map(|arr| {
496            arr.iter()
497                .filter_map(|v| v.as_str().map(String::from))
498                .collect()
499        })
500    })
501}
502
503/// Helper to get either a single string or array of strings from arguments.
504/// Normalizes to a Vec<String>.
505pub fn get_string_or_array(args: &Value, key: &str) -> Option<Vec<String>> {
506    args.get(key).and_then(|v| {
507        if let Some(s) = v.as_str() {
508            // Single string - wrap in vec
509            Some(vec![s.to_string()])
510        } else {
511            v.as_array().map(|arr| {
512                arr.iter()
513                    .filter_map(|item| item.as_str().map(String::from))
514                    .collect()
515            })
516        }
517    })
518}
519
520/// Parsed result that may be a list of IDs or a wildcard "*".
521pub enum IdList {
522    Ids(Vec<String>),
523    Wildcard,
524}
525
526/// Like get_string_or_array, but recognizes "*" as a wildcard sentinel.
527pub fn get_string_or_array_or_wildcard(args: &Value, key: &str) -> Option<IdList> {
528    let vals = get_string_or_array(args, key)?;
529    if vals.len() == 1 && vals[0] == "*" {
530        Some(IdList::Wildcard)
531    } else {
532        Some(IdList::Ids(vals))
533    }
534}