Skip to main content

arbiter_behavior/
classifier.rs

1//! Tool call operation classifier.
2//!
3//! Classifies MCP tool calls into operation types (read/write/delete/admin)
4//! based on the method name and tool name patterns.
5
6use serde::{Deserialize, Serialize};
7
8/// The type of operation a tool call represents.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum OperationType {
12    Read,
13    Write,
14    Delete,
15    Admin,
16}
17
18/// Classify an MCP request by its operation type.
19///
20/// Uses method + tool name patterns:
21/// - `resources/read`, `resources/subscribe`, `completion/complete` → Read
22/// - Tool names containing "read", "get", "list", "search", "view", "describe" → Read
23/// - Tool names containing "delete", "remove", "drop", "purge" → Delete
24/// - Tool names containing "admin", "manage", "configure", "grant", "revoke" → Admin
25/// - Everything else (including "write", "create", "update", "set", "put") → Write
26pub fn classify_operation(method: &str, tool_name: Option<&str>) -> OperationType {
27    // Method-level classification for non-tool-call requests.
28    match method {
29        "resources/read" | "resources/subscribe" | "completion/complete" => {
30            return OperationType::Read;
31        }
32        _ => {}
33    }
34
35    let tool = match tool_name {
36        Some(t) => t.to_lowercase(),
37        None => return OperationType::Read, // Non-tool requests default to read.
38    };
39
40    // Admin patterns (check first: "admin_delete" should be Admin, not Delete).
41    if contains_any_token(&tool, &["admin", "manage", "configure", "grant", "revoke"]) {
42        return OperationType::Admin;
43    }
44
45    // Delete patterns.
46    if contains_any_token(&tool, &["delete", "remove", "drop", "purge"]) {
47        return OperationType::Delete;
48    }
49
50    // Read patterns (includes analytical/reporting operations that don't modify data).
51    if contains_any_token(
52        &tool,
53        &[
54            "read",
55            "get",
56            "list",
57            "search",
58            "view",
59            "describe",
60            "fetch",
61            "query",
62            "analyze",
63            "summarize",
64            "report",
65            "calculate",
66            "compute",
67            "check",
68            "inspect",
69            "review",
70        ],
71    ) {
72        return OperationType::Read;
73    }
74
75    // Default to Write for everything else (create, update, write, set, put, etc.).
76    OperationType::Write
77}
78
79/// Use word-boundary matching instead of substring matching.
80/// Previously "read_and_execute_shell" would match "read" via substring.
81/// Now tool names are split on common delimiters and each token is matched independently.
82fn contains_any_token(haystack: &str, needles: &[&str]) -> bool {
83    let lower = haystack.to_lowercase();
84    // Split on common MCP tool name delimiters: underscore, hyphen, dot, slash, space
85    let tokens: Vec<&str> = lower.split(['_', '-', '.', '/', ' ']).collect();
86    needles.iter().any(|needle| {
87        let lower_needle = needle.to_lowercase();
88        tokens.iter().any(|token| *token == lower_needle)
89    })
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn classify_resource_read_method() {
98        assert_eq!(
99            classify_operation("resources/read", None),
100            OperationType::Read
101        );
102    }
103
104    #[test]
105    fn classify_tool_by_name() {
106        assert_eq!(
107            classify_operation("tools/call", Some("read_file")),
108            OperationType::Read
109        );
110        assert_eq!(
111            classify_operation("tools/call", Some("write_file")),
112            OperationType::Write
113        );
114        assert_eq!(
115            classify_operation("tools/call", Some("delete_resource")),
116            OperationType::Delete
117        );
118        assert_eq!(
119            classify_operation("tools/call", Some("admin_users")),
120            OperationType::Admin
121        );
122    }
123
124    #[test]
125    fn classify_mixed_patterns() {
126        // "get_user" → Read
127        assert_eq!(
128            classify_operation("tools/call", Some("get_user")),
129            OperationType::Read
130        );
131        // "create_user" → Write
132        assert_eq!(
133            classify_operation("tools/call", Some("create_user")),
134            OperationType::Write
135        );
136        // "list_files" → Read
137        assert_eq!(
138            classify_operation("tools/call", Some("list_files")),
139            OperationType::Read
140        );
141    }
142
143    #[test]
144    fn admin_beats_delete() {
145        // "admin_delete" should classify as Admin, not Delete.
146        assert_eq!(
147            classify_operation("tools/call", Some("admin_delete")),
148            OperationType::Admin
149        );
150    }
151
152    /// Novel tool names should not cause panics and should classify sensibly.
153    #[test]
154    fn classify_novel_tool_names() {
155        // "extract_intelligence" -- no standard tokens match, should be Write (default)
156        let result = classify_operation("tools/call", Some("extract_intelligence"));
157        assert_eq!(result, OperationType::Write);
158
159        // "xread_file" -- "xread" is not "read" as a token, should be Write
160        // (word-boundary matching prevents prefix matching)
161        let result = classify_operation("tools/call", Some("xread_file"));
162        assert_eq!(result, OperationType::Write);
163
164        // "readonly_report" -- "report" IS a read token
165        let result = classify_operation("tools/call", Some("readonly_report"));
166        assert_eq!(result, OperationType::Read);
167    }
168
169    /// Empty tool name and empty method should not panic.
170    #[test]
171    fn empty_tool_name() {
172        // Empty tool name defaults to Write (no tokens match any pattern)
173        let result = classify_operation("tools/call", Some(""));
174        assert_eq!(result, OperationType::Write);
175
176        // Empty method with no tool defaults to Read
177        let result = classify_operation("", None);
178        assert_eq!(result, OperationType::Read);
179
180        // Empty method with empty tool name
181        let result = classify_operation("", Some(""));
182        assert_eq!(result, OperationType::Write);
183    }
184
185    /// Tool name consisting only of delimiters should not panic.
186    #[test]
187    fn tool_name_with_only_delimiters() {
188        let result = classify_operation("tools/call", Some("___---..."));
189        // All tokens are empty strings after splitting on delimiters,
190        // no read/write/delete/admin tokens match, so it defaults to Write.
191        assert_eq!(result, OperationType::Write);
192    }
193}