Skip to main content

task_mcp/
config.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4// =============================================================================
5// Task mode
6// =============================================================================
7
8/// Controls which recipes are exposed via MCP.
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
10pub enum TaskMode {
11    /// Only recipes tagged with `[allow-agent]` (group attribute or comment tag).
12    #[default]
13    AgentOnly,
14    /// All non-private recipes.
15    All,
16}
17
18impl TaskMode {
19    /// Parse from the `TASK_MCP_MODE` environment variable value.
20    fn from_env_value(val: &str) -> Self {
21        match val.trim().to_lowercase().as_str() {
22            "all" => Self::All,
23            _ => Self::AgentOnly,
24        }
25    }
26}
27
28// =============================================================================
29// Config
30// =============================================================================
31
32/// Runtime configuration for task-mcp.
33#[derive(Debug, Clone, Default)]
34pub struct Config {
35    /// Filtering mode for exposed recipes.
36    pub mode: TaskMode,
37    /// Path to the justfile (relative or absolute).
38    pub justfile_path: Option<String>,
39    /// Allowed working directories for session_start.
40    /// Empty means no restriction (all directories allowed).
41    pub allowed_dirs: Vec<PathBuf>,
42    /// Path to a custom justfile template used by the `init` tool.
43    pub init_template_file: Option<String>,
44}
45
46impl Config {
47    /// Load configuration from `.task-mcp.env` (if present) and environment variables.
48    ///
49    /// Priority: env var > `.task-mcp.env` > default.
50    pub fn load() -> Self {
51        // Load .task-mcp.env if it exists; ignore errors (file may not be present).
52        let _ = dotenvy::from_filename(".task-mcp.env");
53
54        let mode = env::var("TASK_MCP_MODE")
55            .map(|v| TaskMode::from_env_value(&v))
56            .unwrap_or_default();
57
58        let justfile_path = env::var("TASK_MCP_JUSTFILE").ok();
59
60        let allowed_dirs = env::var("TASK_MCP_ALLOWED_DIRS")
61            .map(|v| parse_allowed_dirs(&v))
62            .unwrap_or_default();
63
64        let init_template_file = env::var("TASK_MCP_INIT_TEMPLATE_FILE").ok();
65
66        Self {
67            mode,
68            justfile_path,
69            allowed_dirs,
70            init_template_file,
71        }
72    }
73
74    /// Check whether `workdir` is permitted.
75    ///
76    /// Returns `true` when `allowed_dirs` is empty (no restriction) or when
77    /// `workdir` starts with at least one entry in `allowed_dirs`.
78    pub fn is_workdir_allowed(&self, workdir: &Path) -> bool {
79        if self.allowed_dirs.is_empty() {
80            return true;
81        }
82        self.allowed_dirs.iter().any(|d| workdir.starts_with(d))
83    }
84}
85
86/// Parse `TASK_MCP_ALLOWED_DIRS` value into canonicalized `PathBuf`s.
87///
88/// Comma-separated entries are canonicalized. Entries that fail canonicalization
89/// (e.g. directory does not exist) are skipped with a warning to stderr.
90fn parse_allowed_dirs(raw: &str) -> Vec<PathBuf> {
91    raw.split(',')
92        .map(str::trim)
93        .filter(|s| !s.is_empty())
94        .filter_map(|entry| match std::fs::canonicalize(entry) {
95            Ok(p) => Some(p),
96            Err(e) => {
97                eprintln!("task-mcp: TASK_MCP_ALLOWED_DIRS: skipping {entry:?}: {e}");
98                None
99            }
100        })
101        .collect()
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn task_mode_default_is_agent_only() {
110        assert_eq!(TaskMode::default(), TaskMode::AgentOnly);
111    }
112
113    #[test]
114    fn task_mode_from_env_value_all() {
115        assert_eq!(TaskMode::from_env_value("all"), TaskMode::All);
116        assert_eq!(TaskMode::from_env_value("ALL"), TaskMode::All);
117        assert_eq!(TaskMode::from_env_value("All"), TaskMode::All);
118    }
119
120    #[test]
121    fn task_mode_from_env_value_agent_only() {
122        assert_eq!(TaskMode::from_env_value("agent-only"), TaskMode::AgentOnly);
123        assert_eq!(TaskMode::from_env_value("unknown"), TaskMode::AgentOnly);
124        assert_eq!(TaskMode::from_env_value(""), TaskMode::AgentOnly);
125    }
126
127    #[test]
128    fn config_default() {
129        let cfg = Config::default();
130        assert_eq!(cfg.mode, TaskMode::AgentOnly);
131        assert!(cfg.justfile_path.is_none());
132        assert!(cfg.allowed_dirs.is_empty());
133    }
134
135    #[test]
136    fn is_workdir_allowed_empty_allows_all() {
137        let cfg = Config {
138            allowed_dirs: vec![],
139            ..Config::default()
140        };
141        assert!(cfg.is_workdir_allowed(Path::new("/any/path")));
142        assert!(cfg.is_workdir_allowed(Path::new("/")));
143    }
144
145    #[test]
146    fn is_workdir_allowed_match() {
147        let cfg = Config {
148            allowed_dirs: vec![PathBuf::from("/home/user/projects")],
149            ..Config::default()
150        };
151        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects")));
152        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects/foo")));
153        assert!(cfg.is_workdir_allowed(Path::new("/home/user/projects/foo/bar")));
154    }
155
156    #[test]
157    fn is_workdir_allowed_no_match() {
158        let cfg = Config {
159            allowed_dirs: vec![PathBuf::from("/home/user/projects")],
160            ..Config::default()
161        };
162        // Sibling directory — not under allowed path
163        assert!(!cfg.is_workdir_allowed(Path::new("/home/user/other")));
164        // Path prefix match but not a directory boundary
165        assert!(!cfg.is_workdir_allowed(Path::new("/home/user/projects-extra")));
166        assert!(!cfg.is_workdir_allowed(Path::new("/home/user")));
167    }
168}