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",
54    "get",
55    "search",
56    "current",
57    "pages",
58    "followers",
59    "members",
60    "history",
61    "whoami",
62    "check",
63    "replies",
64    "tagged",
65    "query",
66];
67
68const WRITE_VERBS: &[&str] = &[
69    "create", "update", "set", "add", "start", "stop", "move", "apply", "invite", "rename",
70    "share", "attach", "link", "reply", "send", "dm", "edit", "upload",
71];
72
73const DESTRUCTIVE_VERBS: &[&str] = &["delete", "remove", "unshare", "unlink", "unset"];
74
75/// Tools that don't fit the naming convention. Each entry shortcircuits
76/// the auto-deriver.
77const OVERRIDES: &[(&str, Class, &str)] = &[
78    ("clickup_search", Class::Read, "workspace"),
79    ("clickup_whoami", Class::Read, "auth"),
80    ("clickup_workspace_plan", Class::Read, "workspace"),
81    ("clickup_workspace_seats", Class::Read, "workspace"),
82    ("clickup_task_replace_estimates", Class::Write, "task"),
83    ("clickup_task_time_in_status", Class::Read, "task"),
84    ("clickup_time_tags", Class::Read, "time"),
85    ("clickup_template_apply_list", Class::Write, "template"),
86    ("clickup_doc_get_page", Class::Read, "doc"),
87    ("clickup_chat_tagged_users", Class::Read, "chat"),
88    ("clickup_view_tasks", Class::Read, "view"),
89    ("clickup_guest_share_list", Class::Write, "guest"),
90];
91
92pub fn classify(tool_name: &str) -> Option<ToolMeta> {
93    // Step 1: override table
94    if let Some(&(_, class, group)) = OVERRIDES.iter().find(|(n, _, _)| *n == tool_name) {
95        return Some(ToolMeta { class, group });
96    }
97
98    // Step 2: group prefix (longest match wins because two-word entries come first)
99    let rest = tool_name.strip_prefix("clickup_")?;
100    let (raw_prefix, normalized_group) = KNOWN_GROUPS
101        .iter()
102        .find(|(prefix, _)| rest == *prefix || rest.starts_with(&format!("{}_", prefix)))
103        .copied()?;
104    let remainder = rest
105        .strip_prefix(raw_prefix)
106        .and_then(|r| r.strip_prefix('_'))
107        .unwrap_or("");
108
109    if remainder.is_empty() {
110        return None;
111    }
112
113    let segments: Vec<&str> = remainder.split('_').collect();
114    let last = *segments.last().unwrap();
115
116    // Step 4: destructive anywhere
117    if segments.iter().any(|s| DESTRUCTIVE_VERBS.contains(s)) {
118        return Some(ToolMeta {
119            class: Class::Destructive,
120            group: normalized_group,
121        });
122    }
123
124    // Step 5: trailing verb
125    if WRITE_VERBS.contains(&last) {
126        return Some(ToolMeta {
127            class: Class::Write,
128            group: normalized_group,
129        });
130    }
131    if READ_VERBS.contains(&last) {
132        return Some(ToolMeta {
133            class: Class::Read,
134            group: normalized_group,
135        });
136    }
137
138    // Step 6: any write segment
139    if segments.iter().any(|s| WRITE_VERBS.contains(s)) {
140        return Some(ToolMeta {
141            class: Class::Write,
142            group: normalized_group,
143        });
144    }
145
146    None
147}
148
149pub const ALL_GROUPS: &[&str] = &[
150    "auth",
151    "workspace",
152    "space",
153    "folder",
154    "list",
155    "task",
156    "checklist",
157    "comment",
158    "tag",
159    "field",
160    "task-type",
161    "attachment",
162    "time",
163    "goal",
164    "view",
165    "member",
166    "user",
167    "chat",
168    "doc",
169    "webhook",
170    "template",
171    "guest",
172    "group",
173    "role",
174    "shared",
175    "audit-log",
176    "acl",
177];
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn destructive_wins_over_write_in_same_name() {
185        assert_eq!(
186            classify("clickup_task_remove_tag").unwrap().class,
187            Class::Destructive
188        );
189    }
190
191    #[test]
192    fn trailing_read_beats_earlier_write() {
193        // reply (write verb) appears before list (read verb); trailing wins
194        assert_eq!(
195            classify("clickup_chat_reply_list").unwrap().class,
196            Class::Read
197        );
198    }
199
200    #[test]
201    fn write_scan_catches_compound_verbs() {
202        assert_eq!(classify("clickup_goal_add_kr").unwrap().class, Class::Write);
203        assert_eq!(
204            classify("clickup_task_add_dep").unwrap().class,
205            Class::Write
206        );
207    }
208
209    #[test]
210    fn two_word_group_prefix_wins() {
211        let m = classify("clickup_task_type_list").unwrap();
212        assert_eq!(m.group, "task-type");
213        assert_eq!(m.class, Class::Read);
214    }
215
216    #[test]
217    fn override_table_short_circuits() {
218        assert_eq!(
219            classify("clickup_task_replace_estimates").unwrap().class,
220            Class::Write
221        );
222        assert_eq!(classify("clickup_search").unwrap().group, "workspace");
223    }
224
225    #[test]
226    fn unknown_tool_returns_none() {
227        assert!(classify("clickup_not_a_real_tool").is_none());
228    }
229}