Skip to main content

clickup_cli/mcp/
classify.rs

1//! Classification of MCP tool names into (Class, group).
2//!
3//! `classify()` is a pure function. The self-check test in
4//! `tests/test_mcp_filter.rs` asserts that every tool in `tool_list()`
5//! classifies without falling through to `None`.
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Class {
9    Read,
10    Write,
11    Destructive,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct ToolMeta {
16    pub class: Class,
17    pub group: &'static str,
18}
19
20/// Known resource groups. Two-word groups come first so prefix matching
21/// in `classify()` prefers them over their one-word prefixes.
22const KNOWN_GROUPS: &[(&str, &str)] = &[
23    ("task_type", "task-type"),
24    ("audit_log", "audit-log"),
25    ("auth", "auth"),
26    ("workspace", "workspace"),
27    ("space", "space"),
28    ("folder", "folder"),
29    ("list", "list"),
30    ("task", "task"),
31    ("checklist", "checklist"),
32    ("comment", "comment"),
33    ("tag", "tag"),
34    ("field", "field"),
35    ("attachment", "attachment"),
36    ("time", "time"),
37    ("goal", "goal"),
38    ("view", "view"),
39    ("member", "member"),
40    ("user", "user"),
41    ("chat", "chat"),
42    ("doc", "doc"),
43    ("webhook", "webhook"),
44    ("template", "template"),
45    ("guest", "guest"),
46    ("group", "group"),
47    ("role", "role"),
48    ("shared", "shared"),
49    ("acl", "acl"),
50];
51
52const READ_VERBS: &[&str] = &[
53    "list", "get", "search", "current", "pages", "followers", "members",
54    "history", "whoami", "check", "replies", "tagged", "query",
55];
56
57const WRITE_VERBS: &[&str] = &[
58    "create", "update", "set", "add", "start", "stop", "move", "apply",
59    "invite", "rename", "share", "attach", "link", "reply", "send", "dm",
60    "edit", "upload",
61];
62
63const DESTRUCTIVE_VERBS: &[&str] = &[
64    "delete", "remove", "unshare", "unlink", "unset",
65];
66
67/// Tools that don't fit the naming convention. Each entry shortcircuits
68/// the auto-deriver.
69const OVERRIDES: &[(&str, Class, &str)] = &[
70    ("clickup_search",                 Class::Read,  "workspace"),
71    ("clickup_whoami",                 Class::Read,  "auth"),
72    ("clickup_workspace_plan",         Class::Read,  "workspace"),
73    ("clickup_workspace_seats",        Class::Read,  "workspace"),
74    ("clickup_task_replace_estimates", Class::Write, "task"),
75    ("clickup_task_time_in_status",    Class::Read,  "task"),
76    ("clickup_time_tags",              Class::Read,  "time"),
77    ("clickup_template_apply_list",    Class::Write, "template"),
78    ("clickup_doc_get_page",           Class::Read,  "doc"),
79    ("clickup_chat_tagged_users",      Class::Read,  "chat"),
80    ("clickup_view_tasks",             Class::Read,  "view"),
81    ("clickup_guest_share_list",       Class::Write, "guest"),
82];
83
84pub fn classify(tool_name: &str) -> Option<ToolMeta> {
85    // Step 1: override table
86    if let Some(&(_, class, group)) = OVERRIDES.iter().find(|(n, _, _)| *n == tool_name) {
87        return Some(ToolMeta { class, group });
88    }
89
90    // Step 2: group prefix (longest match wins because two-word entries come first)
91    let rest = tool_name.strip_prefix("clickup_")?;
92    let (raw_prefix, normalized_group) = KNOWN_GROUPS
93        .iter()
94        .find(|(prefix, _)| rest == *prefix || rest.starts_with(&format!("{}_", prefix)))
95        .copied()?;
96    let remainder = rest
97        .strip_prefix(raw_prefix)
98        .and_then(|r| r.strip_prefix('_'))
99        .unwrap_or("");
100
101    if remainder.is_empty() {
102        return None;
103    }
104
105    let segments: Vec<&str> = remainder.split('_').collect();
106    let last = *segments.last().unwrap();
107
108    // Step 4: destructive anywhere
109    if segments.iter().any(|s| DESTRUCTIVE_VERBS.contains(s)) {
110        return Some(ToolMeta { class: Class::Destructive, group: normalized_group });
111    }
112
113    // Step 5: trailing verb
114    if WRITE_VERBS.contains(&last) {
115        return Some(ToolMeta { class: Class::Write, group: normalized_group });
116    }
117    if READ_VERBS.contains(&last) {
118        return Some(ToolMeta { class: Class::Read, group: normalized_group });
119    }
120
121    // Step 6: any write segment
122    if segments.iter().any(|s| WRITE_VERBS.contains(s)) {
123        return Some(ToolMeta { class: Class::Write, group: normalized_group });
124    }
125
126    None
127}
128
129pub const ALL_GROUPS: &[&str] = &[
130    "auth", "workspace", "space", "folder", "list", "task", "checklist",
131    "comment", "tag", "field", "task-type", "attachment", "time", "goal",
132    "view", "member", "user", "chat", "doc", "webhook", "template",
133    "guest", "group", "role", "shared", "audit-log", "acl",
134];
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn destructive_wins_over_write_in_same_name() {
142        assert_eq!(classify("clickup_task_remove_tag").unwrap().class, Class::Destructive);
143    }
144
145    #[test]
146    fn trailing_read_beats_earlier_write() {
147        // reply (write verb) appears before list (read verb); trailing wins
148        assert_eq!(classify("clickup_chat_reply_list").unwrap().class, Class::Read);
149    }
150
151    #[test]
152    fn write_scan_catches_compound_verbs() {
153        assert_eq!(classify("clickup_goal_add_kr").unwrap().class, Class::Write);
154        assert_eq!(classify("clickup_task_add_dep").unwrap().class, Class::Write);
155    }
156
157    #[test]
158    fn two_word_group_prefix_wins() {
159        let m = classify("clickup_task_type_list").unwrap();
160        assert_eq!(m.group, "task-type");
161        assert_eq!(m.class, Class::Read);
162    }
163
164    #[test]
165    fn override_table_short_circuits() {
166        assert_eq!(classify("clickup_task_replace_estimates").unwrap().class, Class::Write);
167        assert_eq!(classify("clickup_search").unwrap().group, "workspace");
168    }
169
170    #[test]
171    fn unknown_tool_returns_none() {
172        assert!(classify("clickup_not_a_real_tool").is_none());
173    }
174}